authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-25 20:31:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 20:31:29-07:00
log3b0a495b0e05d11dbf88ff2148e7c21b49508172
tree8bdd9b829c51d7791b2840f8909b9d5afb24bb72
parent6989856aa630611dd7d6dcc8cfe9f97456816284
signature Signed by PGP key B5690EEEBB952194

perf(server): per-thread settlement and PR checks no longer rebuild the whole thread list (#13691)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 173 insertions(+), 21 deletions(-)

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+69
...@@ -30,6 +30,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";...@@ -30,6 +30,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
30import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";30import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
31import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";31import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";
32import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts";32import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts";
33import { readSweepSnapshot } from "../ThreadPullRequestReactor.ts";
33import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";34import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts";
3435
35const asProjectId = (value: string): ProjectId => ProjectId.make(value);36const asProjectId = (value: string): ProjectId => ProjectId.make(value);
...@@ -3574,6 +3575,74 @@ it.effect(...@@ -3574,6 +3575,74 @@ it.effect(
3574 },3575 },
3575);3576);
35763577
3578it.effect("reads one sweep thread and its projects like the shell snapshot", () => {
3579 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
3580 Layer.provide(ThreadBackgroundLiveness.layer),
3581 Layer.provide(ThreadPlanProgress.layer),
3582 Layer.provide(
3583 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
3584 resolve: () =>
3585 Effect.succeed({
3586 canonicalKey: "github.com/acme/web",
3587 provider: "github",
3588 displayName: "acme/web",
3589 locator: {
3590 source: "git-remote" as const,
3591 remoteName: "origin",
3592 remoteUrl: "https://github.com/acme/web.git",
3593 },
3594 }),
3595 }),
3596 ),
3597 Layer.provideMerge(SqlitePersistenceMemory),
3598 );
3599 return Effect.gen(function* () {
3600 const sql = yield* SqlClient.SqlClient;
3601 const query = yield* ProjectionSnapshotQuery;
3602 yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
3603 VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'),
3604 ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'),
3605 ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z')`;
3606 yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, branch_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, snoozed_until, snoozed_at, created_at, updated_at, settled_override, settled_at)
3607 VALUES
3608 ('t-linked', 'p1', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature', '/one/wt', NULL, 'turn-1', '2026-09-02T00:00:00Z', 1, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL),
3609 ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'other', NULL,
3610 '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}',
3611 NULL, NULL, 0, '2026-09-10T00:00:00Z', '2026-09-02T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', 'settled', '2026-09-03T00:00:00Z'),
3612 ('t-other', 'p3', 'Other', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL)`;
3613 yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
3614 VALUES ('t-linked', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z')`;
3615 yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, started_at, completed_at, checkpoint_files_json)
3616 VALUES ('t-linked', 'turn-1', 'completed', '2026-09-02T00:00:00Z', '2026-09-02T00:00:01Z', '2026-09-02T00:00:02Z', '[]')`;
3617 yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at)
3618 VALUES ('t-linked', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:03Z')`;
3619 for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) {
3620 yield* sql`INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
3621 VALUES (${projector}, 9, '2026-09-02T00:00:03Z')`;
3622 }
3623
3624 const full = yield* query.getShellSnapshot();
3625 // The seeded fields must reach the snapshot, or the parity check is empty.
3626 const linked = full.threads.find((thread) => thread.id === ThreadId.make("t-linked"));
3627 assert.strictEqual(full.snapshotSequence, 9);
3628 assert.strictEqual(linked?.linkedPullRequest?.number, 7);
3629 assert.strictEqual(linked?.latestTurn?.turnId, asTurnId("turn-1"));
3630 assert.strictEqual(linked?.session?.status, "ready");
3631
3632 for (const [threadId, projectIds] of [
3633 [ThreadId.make("t-linked"), [asProjectId("p1")]],
3634 // Settlement also needs the project that the saved branch PR names.
3635 [ThreadId.make("t-branch"), [asProjectId("p1"), asProjectId("p2")]],
3636 ] as const) {
3637 assert.deepStrictEqual(yield* readSweepSnapshot(query, threadId), {
3638 snapshotSequence: full.snapshotSequence,
3639 projects: full.projects.filter((project) => projectIds.includes(project.id)),
3640 threads: full.threads.filter((thread) => thread.id === threadId),
3641 });
3642 }
3643 }).pipe(Effect.provide(layer));
3644});
3645
3577projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => {3646projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => {
3578 it.effect("lists one kind across active threads only, without hydrating the threads", () =>3647 it.effect("lists one kind across active threads only, without hydrating the threads", () =>
3579 Effect.gen(function* () {3648 Effect.gen(function* () {
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+39-4
...@@ -21,6 +21,7 @@ import * as Deferred from "effect/Deferred";...@@ -21,6 +21,7 @@ import * as Deferred from "effect/Deferred";
21import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";22import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
24import * as PubSub from "effect/PubSub";25import * as PubSub from "effect/PubSub";
25import * as Queue from "effect/Queue";26import * as Queue from "effect/Queue";
26import * as Ref from "effect/Ref";27import * as Ref from "effect/Ref";
...@@ -142,7 +143,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options...@@ -142,7 +143,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options
142 threads: options.threads,143 threads: options.threads,
143 updatedAt: NOW,144 updatedAt: NOW,
144 });145 });
145 const reads = yield* Queue.unbounded<void>();146 // Each shell read: a thread id for a one-thread read, null for a full read.
147 const reads = yield* Queue.unbounded<ThreadId | null>();
146 const events = yield* PubSub.unbounded<OrchestrationEvent>();148 const events = yield* PubSub.unbounded<OrchestrationEvent>();
147 const commands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]);149 const commands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]);
148 const branchCalls = yield* Ref.make<150 const branchCalls = yield* Ref.make<
...@@ -152,8 +154,24 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options...@@ -152,8 +154,24 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options
152 let uuid = 0;154 let uuid = 0;
153 const dependencies = Layer.mergeAll(155 const dependencies = Layer.mergeAll(
154 Layer.mock(ProjectionSnapshotQuery)({156 Layer.mock(ProjectionSnapshotQuery)({
155 getShellSnapshot: () =>157 getShellSnapshot: () => Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, null))),
156 Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))),158 getSnapshotSequence: () =>
159 Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))),
160 getThreadShellById: (threadId) =>
161 Ref.get(snapshots).pipe(
162 Effect.map(({ threads }) =>
163 Option.fromUndefinedOr(
164 threads.find((thread) => thread.id === threadId && thread.archivedAt === null),
165 ),
166 ),
167 Effect.tap(() => Queue.offer(reads, threadId)),
168 ),
169 getProjectShells: (projectIds) =>
170 Ref.get(snapshots).pipe(
171 Effect.map(({ projects }) =>
172 projects.filter((project) => projectIds?.includes(project.id) ?? true),
173 ),
174 ),
157 }),175 }),
158 Layer.mock(GitManager)({176 Layer.mock(GitManager)({
159 branchPullRequest: (input, readOptions) =>177 branchPullRequest: (input, readOptions) =>
...@@ -376,7 +394,7 @@ describe("ThreadPullRequestReactor", () => {...@@ -376,7 +394,7 @@ describe("ThreadPullRequestReactor", () => {
376 : [checkpointEvent, sessionEvent];394 : [checkpointEvent, sessionEvent];
377 for (const event of events) {395 for (const event of events) {
378 yield* fixture.publish(event);396 yield* fixture.publish(event);
379 yield* Queue.take(fixture.reads);397 expect(yield* Queue.take(fixture.reads)).toBe(current.id);
380 yield* reactor.drain;398 yield* reactor.drain;
381 }399 }
382 expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));400 expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
...@@ -516,6 +534,23 @@ describe("ThreadPullRequestReactor", () => {...@@ -516,6 +534,23 @@ describe("ThreadPullRequestReactor", () => {
516 yield* Effect.gen(function* () {534 yield* Effect.gen(function* () {
517 const reactor = yield* fixture.start();535 const reactor = yield* fixture.start();
518 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);536 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
537 // A one-thread read cannot show that other pending threads are gone.
538 const gone = ThreadId.make("gone");
539 yield* fixture.publish({
540 type: "thread.unarchived",
541 sequence: 2,
542 eventId: EventId.make("gone-unarchived"),
543 aggregateKind: "thread",
544 aggregateId: gone,
545 occurredAt: NOW,
546 commandId: null,
547 causationEventId: null,
548 correlationId: null,
549 metadata: {},
550 payload: { threadId: gone, updatedAt: NOW },
551 });
552 expect(yield* Queue.take(fixture.reads)).toBe(gone);
553 yield* reactor.drain;
519 yield* Ref.set(online, true);554 yield* Ref.set(online, true);
520 yield* TestClock.adjust("1 minute");555 yield* TestClock.adjust("1 minute");
521 yield* Queue.take(fixture.reads);556 yield* Queue.take(fixture.reads);
apps/server/src/orchestration/ThreadPullRequestReactor.ts+36-3
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 CommandId,6 CommandId,
7 type OrchestrationEvent,7 type OrchestrationEvent,
8 type OrchestrationProjectShell,8 type OrchestrationProjectShell,
9 type OrchestrationShellSnapshot,
9 type ThreadId,10 type ThreadId,
10 type ThreadLinkedPullRequest,11 type ThreadLinkedPullRequest,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
...@@ -16,11 +17,13 @@ import * as Crypto from "effect/Crypto";...@@ -16,11 +17,13 @@ import * as Crypto from "effect/Crypto";
16import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";18import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
19import * as Schedule from "effect/Schedule";21import * as Schedule from "effect/Schedule";
20import type * as Scope from "effect/Scope";22import type * as Scope from "effect/Scope";
21import * as Stream from "effect/Stream";23import * as Stream from "effect/Stream";
2224
23import * as GitManager from "../git/GitManager.ts";25import * as GitManager from "../git/GitManager.ts";
26import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
24import * as PullRequestService from "../pullRequest/PullRequestService.ts";27import * as PullRequestService from "../pullRequest/PullRequestService.ts";
25import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";28import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
26import { forkParked } from "../serverActivation.ts";29import { forkParked } from "../serverActivation.ts";
...@@ -69,6 +72,35 @@ export function pullRequestMatchesProject(...@@ -69,6 +72,35 @@ export function pullRequestMatchesProject(
69 );72 );
70}73}
7174
75/**
76 * Read the shell state for a discovery or settlement sweep. A sweep for one
77 * thread reads that thread and the projects it names, not every thread.
78 */
79export const readSweepSnapshot = (
80 snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQueryShape,
81 threadId: ThreadId | null,
82): Effect.Effect<
83 Pick<OrchestrationShellSnapshot, "snapshotSequence" | "projects" | "threads">,
84 ProjectionRepositoryError
85> =>
86 threadId === null
87 ? snapshots.getShellSnapshot()
88 : Effect.gen(function* () {
89 // Read the sequence first. The thread is then at least this new, so a
90 // command guarded by the sequence is rejected rather than missing a change.
91 const { snapshotSequence } = yield* snapshots.getSnapshotSequence();
92 const thread = yield* snapshots.getThreadShellById(threadId);
93 if (Option.isNone(thread)) return { snapshotSequence, projects: [], threads: [] };
94 // Settlement also checks the project a saved pull request names.
95 const reference = thread.value.linkedPullRequest ?? thread.value.branchPullRequest;
96 const projects = yield* snapshots.getProjectShells(
97 reference == null
98 ? [thread.value.projectId]
99 : [thread.value.projectId, reference.projectId],
100 );
101 return { snapshotSequence, projects, threads: [thread.value] };
102 });
103
72/** @public Service construction is part of the canonical Effect module API. */104/** @public Service construction is part of the canonical Effect module API. */
73export const make = Effect.gen(function* () {105export const make = Effect.gen(function* () {
74 const engine = yield* OrchestrationEngine.OrchestrationEngineService;106 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
...@@ -97,7 +129,7 @@ export const make = Effect.gen(function* () {...@@ -97,7 +129,7 @@ export const make = Effect.gen(function* () {
97 const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* (129 const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* (
98 request: RefreshRequest,130 request: RefreshRequest,
99 ) {131 ) {
100 const snapshot = yield* snapshots.getShellSnapshot();132 const snapshot = yield* readSweepSnapshot(snapshots, request.threadId);
101 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));133 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
102 if (request.backfill) {134 if (request.backfill) {
103 for (const thread of snapshot.threads) {135 for (const thread of snapshot.threads) {
...@@ -109,14 +141,15 @@ export const make = Effect.gen(function* () {...@@ -109,14 +141,15 @@ export const make = Effect.gen(function* () {
109 }141 }
110 }142 }
111 }143 }
144 // A single-thread read only shows whether its own thread is gone.
112 const threadIds = new Set(snapshot.threads.map((thread) => thread.id));145 const threadIds = new Set(snapshot.threads.map((thread) => thread.id));
113 for (const threadId of pendingBackfill.keys()) {146 const checkedIds = request.threadId === null ? pendingBackfill.keys() : [request.threadId];
147 for (const threadId of checkedIds) {
114 if (!threadIds.has(threadId)) pendingBackfill.delete(threadId);148 if (!threadIds.has(threadId)) pendingBackfill.delete(threadId);
115 }149 }
116 const threads = snapshot.threads.filter(150 const threads = snapshot.threads.filter(
117 (thread) =>151 (thread) =>
118 thread.archivedAt === null &&152 thread.archivedAt === null &&
119 (request.threadId === null || thread.id === request.threadId) &&
120 ((thread.settledOverride !== "settled" && thread.settledAt === null) ||153 ((thread.settledOverride !== "settled" && thread.settledAt === null) ||
121 request.threadId !== null ||154 request.threadId !== null ||
122 pendingBackfill.has(thread.id)) &&155 pendingBackfill.has(thread.id)) &&
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+25-6
...@@ -24,6 +24,7 @@ import * as Effect from "effect/Effect";...@@ -24,6 +24,7 @@ import * as Effect from "effect/Effect";
24import * as Fiber from "effect/Fiber";24import * as Fiber from "effect/Fiber";
25import * as FileSystem from "effect/FileSystem";25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
27import * as PubSub from "effect/PubSub";28import * as PubSub from "effect/PubSub";
28import * as Queue from "effect/Queue";29import * as Queue from "effect/Queue";
29import * as Ref from "effect/Ref";30import * as Ref from "effect/Ref";
...@@ -181,7 +182,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -181,7 +182,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
181 const activation = yield* Deferred.make<void>();182 const activation = yield* Deferred.make<void>();
182 const snapshots = yield* Ref.make(options.snapshot);183 const snapshots = yield* Ref.make(options.snapshot);
183 const snapshotReadCount = yield* Ref.make(0);184 const snapshotReadCount = yield* Ref.make(0);
184 const snapshotReads = yield* Queue.unbounded<number>();185 // Each shell read: a thread id for a one-thread read, null for a full read.
186 const snapshotReads = yield* Queue.unbounded<ThreadId | null>();
185 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);187 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
186 const settingsReads = yield* Queue.unbounded<ServerSettings>();188 const settingsReads = yield* Queue.unbounded<ServerSettings>();
187 const settingsChanges = yield* PubSub.unbounded<ServerSettings>();189 const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
...@@ -255,10 +257,27 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -255,10 +257,27 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
255 const dependencies = Layer.mergeAll(257 const dependencies = Layer.mergeAll(
256 Layer.mock(ProjectionSnapshotQuery)({258 Layer.mock(ProjectionSnapshotQuery)({
257 getShellSnapshot: () =>259 getShellSnapshot: () =>
258 Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(260 Ref.update(snapshotReadCount, (count) => count + 1).pipe(
259 Effect.tap((count) => Queue.offer(snapshotReads, count)),261 Effect.andThen(Queue.offer(snapshotReads, null)),
260 Effect.andThen(Ref.get(snapshots)),262 Effect.andThen(Ref.get(snapshots)),
261 ),263 ),
264 getSnapshotSequence: () =>
265 Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))),
266 getThreadShellById: (threadId) =>
267 Ref.get(snapshots).pipe(
268 Effect.map(({ threads }) =>
269 Option.fromUndefinedOr(
270 threads.find((thread) => thread.id === threadId && thread.archivedAt === null),
271 ),
272 ),
273 Effect.tap(() => Queue.offer(snapshotReads, threadId)),
274 ),
275 getProjectShells: (projectIds) =>
276 Ref.get(snapshots).pipe(
277 Effect.map(({ projects }) =>
278 projects.filter((project) => projectIds?.includes(project.id) ?? true),
279 ),
280 ),
262 }),281 }),
263 Layer.mock(GitManager)({282 Layer.mock(GitManager)({
264 branchPullRequest,283 branchPullRequest,
...@@ -313,7 +332,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -313,7 +332,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
313const startHarness = Effect.fn("startThreadSettlementHarness")(function* (332const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
314 reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"],333 reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"],
315 activation: Deferred.Deferred<void>,334 activation: Deferred.Deferred<void>,
316 snapshotReads: Queue.Queue<number>,335 snapshotReads: Queue.Queue<ThreadId | null>,
317) {336) {
318 yield* reactor.start();337 yield* reactor.start();
319 yield* Deferred.succeed(activation, undefined);338 yield* Deferred.succeed(activation, undefined);
...@@ -443,7 +462,7 @@ describe("ThreadSettlementReactor", () => {...@@ -443,7 +462,7 @@ describe("ThreadSettlementReactor", () => {
443 updatedAt: NOW,462 updatedAt: NOW,
444 },463 },
445 });464 });
446 yield* Queue.take(fixture.snapshotReads);465 assert.strictEqual(yield* Queue.take(fixture.snapshotReads), thread.id);
447 yield* reactor.drain;466 yield* reactor.drain;
448 }467 }
449 assert.deepStrictEqual(468 assert.deepStrictEqual(
...@@ -463,7 +482,7 @@ describe("ThreadSettlementReactor", () => {...@@ -463,7 +482,7 @@ describe("ThreadSettlementReactor", () => {
463 aggregateId: readySession.threadId,482 aggregateId: readySession.threadId,
464 payload: { threadId: readySession.threadId, session: readySession },483 payload: { threadId: readySession.threadId, session: readySession },
465 });484 });
466 yield* Queue.take(fixture.snapshotReads);485 assert.strictEqual(yield* Queue.take(fixture.snapshotReads), readySession.threadId);
467 yield* reactor.drain;486 yield* reactor.drain;
468 assert.deepStrictEqual(487 assert.deepStrictEqual(
469 (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId),488 (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId),
apps/server/src/orchestration/ThreadSettlementReactor.ts+4-8
...@@ -23,7 +23,7 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -23,7 +23,7 @@ import * as ServerSettings from "../serverSettings.ts";
23import { forkParked } from "../serverActivation.ts";23import { forkParked } from "../serverActivation.ts";
24import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";24import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
25import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";25import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
26import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts";26import { pullRequestMatchesProject, readSweepSnapshot } from "./ThreadPullRequestReactor.ts";
27import {27import {
28 isAutoSettlementCandidate,28 isAutoSettlementCandidate,
29 resolveAutoSettlementAt,29 resolveAutoSettlementAt,
...@@ -95,20 +95,16 @@ export const make = Effect.gen(function* () {...@@ -95,20 +95,16 @@ export const make = Effect.gen(function* () {
95 if (!autoSettlementConfigured(settings)) {95 if (!autoSettlementConfigured(settings)) {
96 return;96 return;
97 }97 }
98 const snapshot = yield* snapshots.getShellSnapshot();98 const snapshot = yield* readSweepSnapshot(snapshots, threadId ?? null);
99 const now = DateTime.formatIso(yield* DateTime.now);99 const now = DateTime.formatIso(yield* DateTime.now);
100 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));100 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
101 // A merge rechecks all candidates, including branches that discovery has101 // A merge rechecks all candidates, including branches that discovery has
102 // not linked yet. Those lookups can still have cached the PR as open.102 // not linked yet. Those lookups can still have cached the PR as open.
103 const candidates = snapshot.threads.filter(103 const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
104 (thread) =>
105 (threadId === undefined || thread.id === threadId) &&
106 isAutoSettlementCandidate(thread, now),
107 );
108104
109 // Return the thread when it still needs a pull request decision. A rejected105 // Return the thread when it still needs a pull request decision. A rejected
110 // dispatch skips it for this snapshot instead of retrying through a lookup.106 // dispatch skips it for this snapshot instead of retrying through a lookup.
111 const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")(107 const settleThread = Effect.fnUntraced(
112 function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {108 function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {
113 const settings = resolveProjectSettings(109 const settings = resolveProjectSettings(
114 yield* settingsService.getSettings,110 yield* settingsService.getSettings,