Skip to content

Commit 437b334

Browse files
perf(server): port #13691 to V2 — per-thread settlement and PR checks no longer read every thread
Main made single-thread settlement and pull request sweeps read only that thread. V2 had the same shape: every finished run, detached session, checkpoint or metadata change queued a one-thread request, and each one read every thread before filtering to the one it wanted. - ThreadSettlementServiceV2: getSettlementCandidates takes an optional thread id and filters in SQL (and in the memory store), and a sweep with no candidates stops before reading projects. - ThreadPullRequestServiceV2: a one-thread request reads the thread's sequence, then its shell via getThreadShell, instead of getShellSnapshot (every active and archived thread). As on main, a one-thread read only clears that thread's pending backfill. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent af6754b commit 437b334

5 files changed

Lines changed: 228 additions & 20 deletions

File tree

‎apps/server/src/orchestration-v2/ProjectionStore.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,10 @@ export interface ProjectionStoreV2Shape {
333333
readonly autoResume: boolean;
334334
readonly snooze: boolean;
335335
}) => Effect.Effect<ReadonlyArray<ProjectionLimitRecoveryCandidate>, ProjectionStoreV2Error>;
336-
readonly getSettlementCandidates: () => Effect.Effect<
337-
ReadonlyArray<ProjectionSettlementCandidate>,
338-
ProjectionStoreV2Error
339-
>;
336+
/** Every candidate, or only `threadId` when a sweep checks one thread. */
337+
readonly getSettlementCandidates: (
338+
threadId?: ThreadId,
339+
) => Effect.Effect<ReadonlyArray<ProjectionSettlementCandidate>, ProjectionStoreV2Error>;
340340
/**
341341
* Active (not deleted, not archived) threads with at least one pull request
342342
* link, in shell snapshot order. Skips run, message and item reads.
@@ -4941,7 +4941,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
49414941
return { providerThreadsByThreadId, pendingTurnItemsByThreadId };
49424942
});
49434943

4944-
const getSettlementCandidates: ProjectionStoreV2Shape["getSettlementCandidates"] = () =>
4944+
const getSettlementCandidates: ProjectionStoreV2Shape["getSettlementCandidates"] = (threadId) =>
49454945
sql
49464946
.withTransaction(
49474947
Effect.gen(function* () {
@@ -4969,7 +4969,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
49694969
ORDER BY latest.ordinal DESC, latest.run_id DESC
49704970
LIMIT 1
49714971
)
4972-
WHERE t.deleted_at IS NULL
4972+
WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}
49734973
AND json_extract(t.payload_json, '$.archivedAt') IS NULL
49744974
AND json_extract(t.payload_json, '$.settledOverride') IS NULL
49754975
AND json_extract(t.payload_json, '$.pinnedAt') IS NULL
@@ -5430,12 +5430,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
54305430
}
54315431
return projection.thread;
54325432
}),
5433-
getSettlementCandidates: () =>
5433+
getSettlementCandidates: (threadId) =>
54345434
Effect.gen(function* () {
54355435
const projections = (yield* Ref.get(replayState)).projections;
54365436
return [...projections.values()]
54375437
.filter(
54385438
({ thread, runs, runtimeRequests }) =>
5439+
(threadId === undefined || thread.id === threadId) &&
54395440
thread.deletedAt === null &&
54405441
thread.archivedAt === null &&
54415442
thread.settledOverride === null &&

‎apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts‎

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
1-
import { ProjectId, type OrchestrationProjectShell } from "@t3tools/contracts";
1+
import {
2+
EventId,
3+
ProjectId,
4+
ProviderInstanceId,
5+
ThreadId,
6+
type OrchestrationProjectShell,
7+
type OrchestrationV2DomainEvent,
8+
type OrchestrationV2ThreadShell,
9+
} from "@t3tools/contracts";
210
import { describe, expect, it } from "@effect/vitest";
11+
import * as Crypto from "effect/Crypto";
12+
import * as DateTime from "effect/DateTime";
13+
import * as Deferred from "effect/Deferred";
314
import * as Effect from "effect/Effect";
15+
import * as FileSystem from "effect/FileSystem";
16+
import * as Layer from "effect/Layer";
417
import * as Option from "effect/Option";
18+
import * as PubSub from "effect/PubSub";
19+
import * as Queue from "effect/Queue";
20+
import * as Stream from "effect/Stream";
521

22+
import { GitManager } from "../git/GitManager.ts";
23+
import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
24+
import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts";
25+
import { PullRequestService } from "../pullRequest/PullRequestService.ts";
26+
import { ServerActivation } from "../serverActivation.ts";
27+
import { OrchestratorV2 } from "./Orchestrator.ts";
628
import {
29+
make,
730
projectWorkspaceMatchesSnapshot,
831
resolveProjectForPullRequestDiscovery,
932
} from "./ThreadPullRequestService.ts";
@@ -70,3 +93,130 @@ describe("ThreadPullRequestServiceV2 project guard", () => {
7093
expect(projectWorkspaceMatchesSnapshot(currentProject, "/workspace/original")).toBe(true);
7194
});
7295
});
96+
97+
describe("ThreadPullRequestServiceV2 reads", () => {
98+
const NOW = DateTime.makeUnsafe("2026-09-20T00:00:00.000Z");
99+
const threadShell = (id: string): OrchestrationV2ThreadShell => {
100+
const threadId = ThreadId.make(id);
101+
return {
102+
id: threadId,
103+
projectId: ProjectId.make("project-1"),
104+
title: id,
105+
providerInstanceId: ProviderInstanceId.make("codex"),
106+
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
107+
runtimeMode: "full-access",
108+
interactionMode: "default",
109+
branch: null,
110+
worktreePath: null,
111+
activeProviderThreadId: null,
112+
lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null },
113+
forkedFrom: null,
114+
createdBy: "user",
115+
creationSource: "web",
116+
activeRunId: null,
117+
latestRunId: null,
118+
status: "idle",
119+
pendingRuntimeRequest: null,
120+
latestVisibleMessage: null,
121+
latestUserMessageAt: null,
122+
hasActionableProposedPlan: false,
123+
itemCount: 0,
124+
visibleItemCount: 0,
125+
createdAt: NOW,
126+
updatedAt: NOW,
127+
archivedAt: null,
128+
settledOverride: null,
129+
settledAt: null,
130+
lastVisitedAt: null,
131+
deletedAt: null,
132+
};
133+
};
134+
135+
it.effect("an event for one thread reads that thread's shell, not every thread's", () =>
136+
Effect.scoped(
137+
Effect.gen(function* () {
138+
const thread = threadShell("updated-thread");
139+
const other = threadShell("other-thread");
140+
const activation = yield* Deferred.make<void>();
141+
const events = yield* PubSub.unbounded<OrchestrationV2DomainEvent>();
142+
// Each read: the thread id for a one-thread read, null for a full read.
143+
const reads = yield* Queue.unbounded<ThreadId | null>();
144+
const dependencies = Layer.mergeAll(
145+
Layer.mock(OrchestratorV2)({
146+
streamDomainEvents: Stream.fromPubSub(events),
147+
getShellSnapshot: () =>
148+
Queue.offer(reads, null).pipe(
149+
Effect.as({
150+
schemaVersion: 2,
151+
snapshotSequence: 1,
152+
threads: [thread, other],
153+
archivedThreads: [],
154+
}),
155+
),
156+
getThreadEventSequence: () => Effect.succeed(1),
157+
getThreadShell: (threadId) =>
158+
Queue.offer(reads, threadId).pipe(
159+
Effect.as([thread, other].find((candidate) => candidate.id === threadId) ?? null),
160+
),
161+
}),
162+
Layer.mock(ProjectionSnapshotQuery)({
163+
getProjectShellsWithoutEnrichment: () => Effect.succeed([]),
164+
}),
165+
Layer.mock(GitManager)({}),
166+
Layer.mock(PullRequestService)({}),
167+
Layer.mock(RepositoryIdentityResolver)({}),
168+
Layer.succeed(ServerActivation, Deferred.await(activation)),
169+
Layer.succeed(
170+
Crypto.Crypto,
171+
Crypto.make({
172+
randomBytes: (size) => new Uint8Array(size).fill(1),
173+
digest: (_algorithm, data) => Effect.succeed(data),
174+
}),
175+
),
176+
FileSystem.layerNoop({}),
177+
);
178+
179+
yield* Effect.gen(function* () {
180+
const service = yield* make;
181+
yield* service.start();
182+
yield* Deferred.succeed(activation, undefined);
183+
// Startup backfill is a full read.
184+
expect(yield* Queue.take(reads)).toBeNull();
185+
yield* service.drain;
186+
yield* PubSub.publish(events, {
187+
type: "thread.metadata-updated",
188+
id: EventId.make("event:metadata"),
189+
threadId: thread.id,
190+
occurredAt: NOW,
191+
payload: {
192+
createdBy: thread.createdBy,
193+
creationSource: thread.creationSource,
194+
id: thread.id,
195+
projectId: thread.projectId,
196+
title: thread.title,
197+
providerInstanceId: thread.providerInstanceId,
198+
modelSelection: thread.modelSelection,
199+
runtimeMode: thread.runtimeMode,
200+
interactionMode: thread.interactionMode,
201+
branch: thread.branch,
202+
worktreePath: thread.worktreePath,
203+
activeProviderThreadId: thread.activeProviderThreadId,
204+
lineage: thread.lineage,
205+
forkedFrom: null,
206+
createdAt: thread.createdAt,
207+
updatedAt: thread.updatedAt,
208+
archivedAt: null,
209+
settledOverride: null,
210+
settledAt: null,
211+
lastVisitedAt: null,
212+
deletedAt: null,
213+
},
214+
});
215+
expect(yield* Queue.take(reads)).toBe(thread.id);
216+
yield* service.drain;
217+
expect(yield* Queue.size(reads)).toBe(0);
218+
}).pipe(Effect.provide(dependencies));
219+
}),
220+
),
221+
);
222+
});

‎apps/server/src/orchestration-v2/ThreadPullRequestService.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,26 @@ export const make = Effect.gen(function* () {
114114
}
115115
};
116116

117+
/**
118+
* A sweep for one thread reads only that thread's shell, not every thread's.
119+
* Finished runs and checkpoints queue one of these each.
120+
*/
121+
const readThreadSnapshot = (threadId: ThreadId | null) =>
122+
threadId === null
123+
? orchestrator.getShellSnapshot()
124+
: Effect.gen(function* () {
125+
// Read the sequence first. The thread is then at least this new, so a
126+
// sync guarded by the sequence is rejected rather than missing a change.
127+
const snapshotSequence = yield* orchestrator.getThreadEventSequence(threadId);
128+
const thread = yield* orchestrator.getThreadShell(threadId);
129+
return { snapshotSequence, threads: thread === null ? [] : [thread] };
130+
});
131+
117132
const synchronize = Effect.fn("ThreadPullRequestServiceV2.synchronize")(function* (
118133
request: RefreshRequest,
119134
) {
120135
const [threadSnapshot, projectShells] = yield* Effect.all([
121-
orchestrator.getShellSnapshot(),
136+
readThreadSnapshot(request.threadId),
122137
snapshots.getProjectShellsWithoutEnrichment(),
123138
]);
124139
const projects = new Map(projectShells.map((project) => [project.id, project]));
@@ -132,14 +147,15 @@ export const make = Effect.gen(function* () {
132147
}
133148
}
134149
}
150+
// A single-thread read only shows whether its own thread is gone.
135151
const visibleThreadIds = new Set(threadSnapshot.threads.map((thread) => thread.id));
136-
for (const threadId of pendingBackfill.keys()) {
152+
const checkedIds = request.threadId === null ? [...pendingBackfill.keys()] : [request.threadId];
153+
for (const threadId of checkedIds) {
137154
if (!visibleThreadIds.has(threadId)) pendingBackfill.delete(threadId);
138155
}
139156
const threads = threadSnapshot.threads.filter(
140157
(thread) =>
141158
thread.archivedAt === null &&
142-
(request.threadId === null || thread.id === request.threadId) &&
143159
((thread.settledOverride !== "settled" && thread.settledAt === null) ||
144160
request.threadId !== null ||
145161
pendingBackfill.has(thread.id)) &&

‎apps/server/src/orchestration-v2/ThreadSettlementService.test.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ProjectId,
66
EventId,
77
ProviderInstanceId,
8+
ProviderSessionId,
89
ThreadId,
910
type OrchestrationProjectShell,
1011
type OrchestrationV2AppThread,
@@ -423,6 +424,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
423424
const snapshots = yield* Ref.make(options.snapshot);
424425
const snapshotReadCount = yield* Ref.make(0);
425426
const snapshotReads = yield* Queue.unbounded<number>();
427+
const candidateReads = yield* Ref.make<ReadonlyArray<string | undefined>>([]);
426428
const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
427429
const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
428430
const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>();
@@ -501,11 +503,16 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
501503
Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),
502504
}),
503505
Layer.mock(ProjectionStoreV2)({
504-
getSettlementCandidates: () =>
505-
Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
506+
getSettlementCandidates: (threadId) =>
507+
Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe(
508+
Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)),
506509
Effect.tap((count) => Queue.offer(snapshotReads, count)),
507510
Effect.andThen(Ref.get(snapshots)),
508-
Effect.map((snapshot) => snapshot.threads),
511+
Effect.map((snapshot) =>
512+
threadId === undefined
513+
? snapshot.threads
514+
: snapshot.threads.filter((thread) => thread.id === threadId),
515+
),
509516
),
510517
getThread: (threadId) => {
511518
const thread = options.currentThreads?.find((candidate) => candidate.id === threadId);
@@ -544,6 +551,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
544551
snapshots,
545552
snapshotReadCount,
546553
snapshotReads,
554+
candidateReads,
547555
commands,
548556
branchCalls,
549557
summaryCalls,
@@ -1006,3 +1014,38 @@ describe("ThreadSettlementServiceV2 terminals", () => {
10061014
),
10071015
);
10081016
});
1017+
1018+
describe("ThreadSettlementServiceV2 single-thread sweeps", () => {
1019+
it.effect("a finished run reads only its own thread's settlement candidate", () =>
1020+
Effect.scoped(
1021+
Effect.gen(function* () {
1022+
yield* TestClock.setTime(Date.parse(NOW));
1023+
const finished = makeThread("finished-run");
1024+
const other = makeThread("other-thread");
1025+
const fixture = yield* makeHarness({
1026+
snapshot: makeSnapshot([finished, other]),
1027+
settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 3 },
1028+
});
1029+
1030+
yield* Effect.gen(function* () {
1031+
const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1032+
yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1033+
yield* Ref.set(fixture.candidateReads, []);
1034+
yield* fixture.publishEvent({
1035+
type: "provider-session.detached",
1036+
id: EventId.make("event:detached"),
1037+
threadId: finished.id,
1038+
occurredAt: DateTime.makeUnsafe(NOW),
1039+
payload: {
1040+
providerSessionId: ProviderSessionId.make("provider-session:finished"),
1041+
detachedAt: DateTime.makeUnsafe(NOW),
1042+
},
1043+
});
1044+
yield* Queue.take(fixture.snapshotReads);
1045+
yield* service.drain;
1046+
assert.deepStrictEqual(yield* Ref.get(fixture.candidateReads), [finished.id]);
1047+
}).pipe(Effect.provide(fixture.layer));
1048+
}),
1049+
),
1050+
);
1051+
});

‎apps/server/src/orchestration-v2/ThreadSettlementService.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -268,19 +268,17 @@ export const make = Effect.gen(function* () {
268268
if (!autoSettlementConfigured(settings)) {
269269
return;
270270
}
271-
const threads = yield* projections.getSettlementCandidates();
271+
// A sweep for one thread reads only that thread's candidate row.
272+
const threads = yield* projections.getSettlementCandidates(threadId);
273+
if (threads.length === 0) return;
272274
const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment();
273275
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
274276
const projects = new Map(projectShells.map((project) => [project.id, project]));
275277
// A merge event re-sweeps every candidate, not just the threads linked to
276278
// the merged pull request: most threads carry no link and settle from
277279
// their branch lookup, which would otherwise wait for the next minute's
278280
// sweep on a possibly stale cached answer.
279-
const candidates = threads.filter(
280-
(thread) =>
281-
(threadId === undefined || thread.id === threadId) &&
282-
isAutoSettlementCandidate(thread, nowMs),
283-
);
281+
const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs));
284282

285283
const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")(
286284
function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {

0 commit comments

Comments
 (0)