Skip to content

Commit 439629b

Browse files
perf(server): linked-thread counts update when links change instead of polling every 10s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 36371de commit 439629b

6 files changed

Lines changed: 128 additions & 6 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ describe("ThreadPullRequestServiceV2 reads", () => {
201201
// A turn end commits its checkpoints and the run's end together. The queued
202202
// non-refresh request ahead of them must still become a refresh when they join it.
203203
const turnEnd = [
204-
{ type: "thread.metadata-updated", threadId: thread.id },
204+
{ type: "thread.metadata-updated", threadId: thread.id, payload: thread },
205205
{ type: "checkpoint.captured", threadId: thread.id },
206206
{ type: "checkpoint.captured", threadId: thread.id },
207207
{ type: "run.updated", threadId: thread.id, payload: { status: "completed" } },
@@ -211,8 +211,8 @@ describe("ThreadPullRequestServiceV2 reads", () => {
211211
// Arrives during the thread's own lookup, which may already have read the old host state.
212212
const duringLookup = [
213213
{ type: "checkpoint.captured", threadId: thread.id },
214-
{ type: "thread.metadata-updated", threadId: thread.id },
215-
] as Array<OrchestrationV2DomainEvent>;
214+
{ type: "thread.metadata-updated", threadId: thread.id, payload: thread },
215+
] as unknown as Array<OrchestrationV2DomainEvent>;
216216
const activation = yield* Deferred.make<void>();
217217
const sweepStarted = yield* Deferred.make<void>();
218218
const turnEndQueued = yield* Deferred.make<void>();

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as GitManager from "../git/GitManager.ts";
2525
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2626
import * as ProjectStore from "./ProjectStore.ts";
2727
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
28+
import { makeLinkedThreadsChangeDetector } from "../pullRequest/linkedThreads.ts";
2829
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
2930
import { forkParked } from "../serverActivation.ts";
3031
import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
@@ -407,7 +408,15 @@ export const make = Effect.gen(function* () {
407408
return worker.enqueue(request);
408409
});
409410

410-
const processEvent = (event: OrchestrationV2DomainEvent) => {
411+
const linkedThreadsChanged = makeLinkedThreadsChangeDetector();
412+
413+
const processEvent = (event: OrchestrationV2DomainEvent) =>
414+
Effect.andThen(
415+
linkedThreadsChanged(event) ? pullRequests.notifyLinkedThreadsChanged : Effect.void,
416+
refreshForEvent(event),
417+
);
418+
419+
const refreshForEvent = (event: OrchestrationV2DomainEvent) => {
411420
switch (event.type) {
412421
case "thread.created":
413422
case "thread.unarchived":

‎apps/server/src/pullRequest/PullRequestService.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,8 @@ export class PullRequestService extends Context.Service<
224224
input: PullRequestReportStateInput,
225225
) => Effect.Effect<void, PullRequestError>;
226226
readonly subscribeRefreshes: Stream.Stream<number>;
227+
/** Wakes refresh readers when a thread's pull request links change; reads stay cached. */
228+
readonly notifyLinkedThreadsChanged: Effect.Effect<void>;
227229
readonly refreshAfterTurn: (
228230
projectId: ProjectId,
229231
options?: { readonly pushed?: boolean },
@@ -3687,6 +3689,10 @@ export const make = Effect.gen(function* () {
36873689
yield* SubscriptionRef.set(pullRequestRefreshes, epoch);
36883690
});
36893691

3692+
const notifyLinkedThreadsChanged = Effect.suspend(() =>
3693+
SubscriptionRef.set(pullRequestRefreshes, ++epochCounter),
3694+
);
3695+
36903696
const invalidatedByMutation =
36913697
<I extends PullRequestRef>(
36923698
method: (input: I) => Effect.Effect<void, PullRequestError>,
@@ -3886,6 +3892,7 @@ export const make = Effect.gen(function* () {
38863892
subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe(
38873893
Stream.filter((revision) => revision > 0),
38883894
),
3895+
notifyLinkedThreadsChanged,
38893896
refreshAfterTurn,
38903897
detail: credentialCached(detail),
38913898
checks: credentialCached((input) => Cache.get(checksCache, refCacheKey(input))),

‎apps/server/src/pullRequest/linkedThreads.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import {
2+
ProjectId,
3+
ThreadId,
4+
type OrchestrationV2AppThread,
5+
type OrchestrationV2DomainEvent,
6+
type ThreadPullRequestLink,
7+
} from "@t3tools/contracts";
18
import { assert, expect, it } from "@effect/vitest";
9+
import * as DateTime from "effect/DateTime";
210
import * as Effect from "effect/Effect";
311
import * as Schema from "effect/Schema";
412
import * as SqlClient from "effect/sql/SqlClient";
513

614
import * as SqlitePersistence from "../persistence/Sqlite.ts";
7-
import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
15+
import { listLinkedPullRequestThreads, makeLinkedThreadsChangeDetector } from "./linkedThreads.ts";
816

917
const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
1018

@@ -209,3 +217,53 @@ it.effect(
209217
);
210218
}).pipe(Effect.provide(SqlitePersistence.layerMemory)),
211219
);
220+
221+
it("signals linked-thread changes only when a linked answer could change", () => {
222+
const now = DateTime.makeUnsafe("2026-11-12T00:00:00.000Z");
223+
const threadId = ThreadId.make("thread-a");
224+
const link: ThreadPullRequestLink = {
225+
host: "github.com",
226+
repository: "acme/web",
227+
number: 7,
228+
url: "https://github.com/acme/web/pull/7",
229+
source: "agent",
230+
linkedAt: "2026-11-12T00:00:00.000Z",
231+
snapshot: null,
232+
stack: null,
233+
};
234+
const base = {
235+
id: threadId,
236+
projectId: ProjectId.make("project-a"),
237+
title: "Thread A",
238+
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
239+
archivedAt: null,
240+
settledOverride: null,
241+
settledAt: null,
242+
deletedAt: null,
243+
} as unknown as OrchestrationV2AppThread;
244+
const event = (
245+
type: OrchestrationV2DomainEvent["type"],
246+
payload: Partial<OrchestrationV2AppThread>,
247+
) => ({ type, threadId, payload: { ...base, ...payload } }) as OrchestrationV2DomainEvent;
248+
const changed = makeLinkedThreadsChangeDetector();
249+
250+
expect(
251+
[
252+
event("thread.metadata-updated", { title: "Unlinked rename" }),
253+
event("thread.pull-request-synced", { pullRequests: [link] }),
254+
event("thread.pull-request-synced", {
255+
pullRequests: [{ ...link, watch: { since: "2026-11-12T00:00:00.000Z" } as never }],
256+
}),
257+
event("thread.visited", { pullRequests: [link], title: "Ignored event" }),
258+
{ type: "thread.pull-request-link-updated", threadId, payload: link } as never,
259+
event("thread.metadata-updated", { pullRequests: [link], title: "Renamed" }),
260+
event("thread.archived", { pullRequests: [link], title: "Renamed", archivedAt: now }),
261+
event("thread.pull-request-synced", {
262+
pullRequests: [{ ...link, source: "stack-dismissed" }],
263+
title: "Renamed",
264+
archivedAt: now,
265+
}),
266+
event("thread.deleted", { pullRequests: [], deletedAt: now }),
267+
].map(changed),
268+
).toEqual([false, true, false, false, false, true, true, true, false]);
269+
});

‎apps/server/src/pullRequest/linkedThreads.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import {
22
normalizeThreadPullRequestKey,
3+
threadPullRequestKeyOf,
34
threadPullRequestKeysEqual,
5+
visibleThreadPullRequests,
46
} from "../sourceControl/threadPullRequests.ts";
57
import {
68
PullRequestLinkedThreadsResult,
79
PullRequestOperationError,
10+
type OrchestrationV2AppThread,
11+
type OrchestrationV2DomainEvent,
12+
type ThreadId,
813
type ThreadPullRequestKey,
914
} from "@t3tools/contracts";
1015
import * as Effect from "effect/Effect";
@@ -66,3 +71,47 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre
6671
}),
6772
),
6873
);
74+
75+
// Mirrors the rows and filters of `listLinkedPullRequestThreads`.
76+
function linkedThreadsView(thread: OrchestrationV2AppThread): string | null {
77+
if (thread.deletedAt !== null || thread.lineage.sideChat === true) return null;
78+
const keys = visibleThreadPullRequests(thread.pullRequests ?? [])
79+
.map(threadPullRequestKeyOf)
80+
.toSorted();
81+
if (keys.length === 0) return null;
82+
const settled = thread.settledAt !== null || thread.settledOverride === "settled";
83+
return JSON.stringify([
84+
keys,
85+
thread.title,
86+
thread.projectId,
87+
thread.archivedAt !== null,
88+
settled,
89+
]);
90+
}
91+
92+
const LINKED_VIEW_EVENTS = new Set<OrchestrationV2DomainEvent["type"]>([
93+
"thread.created",
94+
"thread.archived",
95+
"thread.unarchived",
96+
"thread.deleted",
97+
"thread.settled",
98+
"thread.unsettled",
99+
"thread.metadata-updated",
100+
"thread.pull-request-synced",
101+
]);
102+
103+
/**
104+
* Tells whether a domain event changed any `pullRequests.linkedThreads` answer, so readers can
105+
* refresh on change instead of polling. A linked thread's first event after a restart counts once.
106+
*/
107+
export function makeLinkedThreadsChangeDetector() {
108+
const views = new Map<ThreadId, string>();
109+
return (event: OrchestrationV2DomainEvent): boolean => {
110+
if (!LINKED_VIEW_EVENTS.has(event.type) || !("lineage" in event.payload)) return false;
111+
const view = linkedThreadsView(event.payload);
112+
const previous = views.get(event.threadId) ?? null;
113+
if (view === null) views.delete(event.threadId);
114+
else views.set(event.threadId, view);
115+
return view !== previous;
116+
};
117+
}

‎packages/client-runtime/src/state/pullRequests.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,6 @@ export function createPullRequestEnvironmentAtoms<R, E>(
709709
persistence: persistQuery(PullRequestLinkedThreadsResult),
710710
tag: WS_METHODS.pullRequestsLinkedThreads,
711711
staleTimeMs: 0,
712-
refreshIntervalMs: 10_000,
713712
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
714713
}),
715714
// For callers that open a thread from the answer: a persisted one reads as final before

0 commit comments

Comments
 (0)