authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-26 01:35:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 01:35:08-07:00
log1d6f23b51929117d5e69ded8c5c0f8e195d75d4a
tree2d3c0b5e15e7d28fee294df3c46b7039d52e0058
parent294dd123410c77eead2810a052c31fb0cfd3e0c1
signature Signed by PGP key B5690EEEBB952194

perf(server): background sweeps only read threads that can still settle (#13765)

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

6 files changed, 349 insertions(+), 30 deletions(-)

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+79
......@@ -3643,6 +3643,85 @@ it.effect("reads one sweep thread and its projects like the shell snapshot", ()
36433643 }).pipe(Effect.provide(layer));
36443644});
36453645
3646it.effect("reads a full sweep from unsettled threads and every project", () => {
3647 const resolved: string[] = [];
3648 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
3649 Layer.provide(ThreadBackgroundLiveness.layer),
3650 Layer.provide(ThreadPlanProgress.layer),
3651 Layer.provide(
3652 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
3653 resolve: (root) =>
3654 Effect.sync(() => {
3655 resolved.push(root);
3656 return null;
3657 }),
3658 }),
3659 ),
3660 Layer.provideMerge(SqlitePersistenceMemory),
3661 );
3662 return Effect.gen(function* () {
3663 const sql = yield* SqlClient.SqlClient;
3664 const query = yield* ProjectionSnapshotQuery;
3665 yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
3666 VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'),
3667 ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'),
3668 ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z'),
3669 ('p4', 'Four', '/four', '[]', '2026-09-04T00:00:00Z', '2026-09-04T00:00:00Z')`;
3670 yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch_pull_request_json, latest_turn_id, created_at, updated_at, archived_at, settled_override, settled_at)
3671 VALUES
3672 ('t-open', 'p1', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-open', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL),
3673 ('t-resumed', 'p1', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'active', NULL),
3674 ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default',
3675 '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}',
3676 NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL),
3677 ('t-settled', 'p3', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-settled', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'settled', '2026-09-03T00:00:00Z'),
3678 ('t-archived', 'p4', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-04T00:00:00Z', NULL, NULL)`;
3679 // The open and the settled thread both have a row in each joined table. The
3680 // settled thread's turn and session are the newest rows, so updatedAt shows
3681 // whether those two reads skip it.
3682 yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
3683 VALUES ('t-open', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z'),
3684 ('t-settled', 'github.com', 'acme/web', 9, 'https://github.com/acme/web/pull/9', 'agent', '2026-09-02T00:00:00Z')`;
3685 yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, checkpoint_files_json)
3686 VALUES ('t-open', 'turn-open', 'completed', '2026-09-02T00:00:00Z', '[]'),
3687 ('t-settled', 'turn-settled', 'completed', '2026-09-09T00:00:00Z', '[]')`;
3688 yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at)
3689 VALUES ('t-open', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:00Z'),
3690 ('t-settled', 'stopped', 'codex', NULL, NULL, '2026-09-10T00:00:00Z')`;
3691
3692 const full = yield* query.getShellSnapshot();
3693 // The settled thread's rows must reach the full read, or skipping them proves nothing.
3694 const settled = full.threads.find((thread) => thread.id === ThreadId.make("t-settled"));
3695 assert.strictEqual(settled?.pullRequests[0]?.number, 9);
3696 assert.strictEqual(settled?.latestTurn?.turnId, asTurnId("turn-settled"));
3697 assert.strictEqual(settled?.session?.status, "stopped");
3698 assert.strictEqual(full.updatedAt, "2026-09-10T00:00:00Z");
3699 resolved.length = 0;
3700
3701 const sweep = yield* readSweepSnapshot(query, null);
3702 assert.strictEqual(sweep.snapshotSequence, full.snapshotSequence);
3703 assert.deepStrictEqual(
3704 sweep.threads,
3705 full.threads.filter((thread) => thread.id !== ThreadId.make("t-settled")),
3706 );
3707 assert.deepStrictEqual(
3708 sweep.threads.map((thread) => thread.id),
3709 ["t-branch", "t-open", "t-resumed"],
3710 );
3711 // Like the full read, the sweep resolves every project, so it keeps the
3712 // repository identity cache warm for client connects.
3713 assert.deepStrictEqual(sweep.projects, full.projects);
3714 assert.deepStrictEqual(resolved.toSorted(), ["/four", "/one", "/three", "/two"]);
3715 // A settled thread's link that no longer decodes breaks the full read, but
3716 // not the sweep, which never reads it.
3717 yield* sql`UPDATE projection_thread_pull_requests SET snapshot_json = 'invalid-json' WHERE thread_id = 't-settled'`;
3718 assert.strictEqual((yield* Effect.exit(query.getShellSnapshot()))._tag, "Failure");
3719 const unsettled = yield* query.getShellSnapshot({ unsettledOnly: true });
3720 assert.deepStrictEqual(unsettled.threads, sweep.threads);
3721 assert.strictEqual(unsettled.updatedAt, "2026-09-04T00:00:00Z");
3722 }).pipe(Effect.provide(layer));
3723});
3724
36463725projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => {
36473726 it.effect("lists one kind across active threads only, without hydrating the threads", () =>
36483727 Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+28-15
......@@ -185,6 +185,7 @@ const EventReplayStatsRowSchema = Schema.Struct({
185185 eventCount: Schema.Number,
186186 payloadBytes: Schema.Number,
187187});
188const ActiveThreadRowsRequest = Schema.Struct({ unsettledOnly: Schema.Boolean });
188189const ProjectionThreadSearchRequest = Schema.Struct({
189190 pattern: Schema.String,
190191 limit: Schema.Int,
......@@ -600,10 +601,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
600601 `,
601602 });
602603
604 // Background sweeps skip settled threads, the same check PR discovery makes.
605 const unsettledThreadsFilter = (unsettledOnly: boolean) =>
606 unsettledOnly
607 ? sql`AND threads.settled_at IS NULL AND threads.settled_override IS NOT 'settled'`
608 : sql``;
609
603610 const listActiveThreadRows = SqlSchema.findAll({
604 Request: Schema.Void,
611 Request: ActiveThreadRowsRequest,
605612 Result: ProjectionThreadDbRowSchema,
606 execute: () =>
613 execute: (request) =>
607614 sql`
608615 SELECT
609616 thread_id AS "threadId",
......@@ -637,9 +644,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
637644 pending_user_input_count AS "pendingUserInputCount",
638645 has_actionable_proposed_plan AS "hasActionableProposedPlan",
639646 deleted_at AS "deletedAt"
640 FROM projection_threads
647 FROM projection_threads threads
641648 WHERE deleted_at IS NULL
642649 AND archived_at IS NULL
650 ${unsettledThreadsFilter(request.unsettledOnly)}
643651 ORDER BY project_id ASC, created_at ASC, thread_id ASC
644652 `,
645653 });
......@@ -779,9 +787,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
779787 });
780788
781789 const listActiveThreadPullRequestRows = SqlSchema.findAll({
782 Request: Schema.Void,
790 Request: ActiveThreadRowsRequest,
783791 Result: ProjectionThreadPullRequestDbRowSchema,
784 execute: () =>
792 execute: (request) =>
785793 sql`
786794 SELECT
787795 links.thread_id AS "threadId",
......@@ -798,6 +806,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
798806 ON threads.thread_id = links.thread_id
799807 WHERE threads.deleted_at IS NULL
800808 AND threads.archived_at IS NULL
809 ${unsettledThreadsFilter(request.unsettledOnly)}
801810 ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC
802811 `,
803812 });
......@@ -907,9 +916,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
907916 });
908917
909918 const listActiveThreadSessionRows = SqlSchema.findAll({
910 Request: Schema.Void,
919 Request: ActiveThreadRowsRequest,
911920 Result: ProjectionThreadSessionDbRowSchema,
912 execute: () =>
921 execute: (request) =>
913922 sql`
914923 SELECT
915924 sessions.thread_id AS "threadId",
......@@ -927,6 +936,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
927936 ON threads.thread_id = sessions.thread_id
928937 WHERE threads.deleted_at IS NULL
929938 AND threads.archived_at IS NULL
939 ${unsettledThreadsFilter(request.unsettledOnly)}
930940 ORDER BY sessions.thread_id ASC
931941 `,
932942 });
......@@ -1001,9 +1011,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
10011011 });
10021012
10031013 const listActiveLatestTurnRows = SqlSchema.findAll({
1004 Request: Schema.Void,
1014 Request: ActiveThreadRowsRequest,
10051015 Result: ProjectionLatestTurnDbRowSchema,
1006 execute: () =>
1016 execute: (request) =>
10071017 sql`
10081018 SELECT
10091019 turns.thread_id AS "threadId",
......@@ -1022,6 +1032,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
10221032 WHERE threads.deleted_at IS NULL
10231033 AND threads.archived_at IS NULL
10241034 AND threads.latest_turn_id IS NOT NULL
1035 ${unsettledThreadsFilter(request.unsettledOnly)}
10251036 ORDER BY turns.thread_id ASC
10261037 `,
10271038 });
......@@ -2654,8 +2665,9 @@ pending_approval_requests AS (
26542665 }),
26552666 );
26562667
2657 const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () =>
2658 sql
2668 const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = (options) => {
2669 const unsettledOnly = options?.unsettledOnly === true;
2670 return sql
26592671 .withTransaction(
26602672 Effect.all([
26612673 listProjectRows(undefined).pipe(
......@@ -2666,7 +2678,7 @@ pending_approval_requests AS (
26662678 ),
26672679 ),
26682680 ),
2669 listActiveThreadRows(undefined).pipe(
2681 listActiveThreadRows({ unsettledOnly }).pipe(
26702682 Effect.mapError(
26712683 toPersistenceSqlOrDecodeError(
26722684 "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query",
......@@ -2674,7 +2686,7 @@ pending_approval_requests AS (
26742686 ),
26752687 ),
26762688 ),
2677 listActiveThreadSessionRows(undefined).pipe(
2689 listActiveThreadSessionRows({ unsettledOnly }).pipe(
26782690 Effect.mapError(
26792691 toPersistenceSqlOrDecodeError(
26802692 "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query",
......@@ -2682,7 +2694,7 @@ pending_approval_requests AS (
26822694 ),
26832695 ),
26842696 ),
2685 listActiveThreadPullRequestRows(undefined).pipe(
2697 listActiveThreadPullRequestRows({ unsettledOnly }).pipe(
26862698 Effect.mapError(
26872699 toPersistenceSqlOrDecodeError(
26882700 "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query",
......@@ -2690,7 +2702,7 @@ pending_approval_requests AS (
26902702 ),
26912703 ),
26922704 ),
2693 listActiveLatestTurnRows(undefined).pipe(
2705 listActiveLatestTurnRows({ unsettledOnly }).pipe(
26942706 Effect.mapError(
26952707 toPersistenceSqlOrDecodeError(
26962708 "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query",
......@@ -2812,6 +2824,7 @@ pending_approval_requests AS (
28122824 return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error);
28132825 }),
28142826 );
2827 };
28152828
28162829 const listThreadsWithPullRequests: ProjectionSnapshotQueryShape["listThreadsWithPullRequests"] =
28172830 () =>
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+8-4
......@@ -120,11 +120,15 @@ export interface ProjectionSnapshotQueryShape {
120120 *
121121 * Returns only projects and thread shell summaries so clients can bootstrap
122122 * lightweight navigation state without hydrating every thread body.
123 *
124 * `unsettledOnly` is for background sweeps, not clients. It skips settled
125 * threads and their sessions, PR links, and turns, and its `updatedAt`
126 * ignores those rows. It still resolves every project, which keeps
127 * repository identities cached for client connects.
123128 */
124 readonly getShellSnapshot: () => Effect.Effect<
125 OrchestrationShellSnapshot,
126 ProjectionRepositoryError
127 >;
129 readonly getShellSnapshot: (options?: {
130 readonly unsettledOnly?: boolean;
131 }) => Effect.Effect<OrchestrationShellSnapshot, ProjectionRepositoryError>;
128132
129133 /**
130134 * Read archived thread shell summaries for the archive page.
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+122-2
......@@ -27,13 +27,21 @@ import * as Queue from "effect/Queue";
2727import * as Ref from "effect/Ref";
2828import * as Stream from "effect/Stream";
2929import { TestClock } from "effect/testing";
30import * as SqlClient from "effect/unstable/sql/SqlClient";
3031
3132import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts";
33import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
3234import { PullRequestService } from "../pullRequest/PullRequestService.ts";
3335import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts";
3436import { ServerActivation } from "../serverActivation.ts";
37import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
3538import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
36import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
39import {
40 ProjectionSnapshotQuery,
41 type ProjectionSnapshotQueryShape,
42} from "./Services/ProjectionSnapshotQuery.ts";
43import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
44import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
3745import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts";
3846
3947const NOW = "2026-09-01T12:00:00.000Z";
......@@ -135,6 +143,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options
135143 readonly existingWorktrees?: ReadonlyArray<string>;
136144 readonly project?: OrchestrationProjectShell;
137145 readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"];
146 /** Serve full sweep reads from this instead of `threads`. */
147 readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"];
138148}) {
139149 const activation = yield* Deferred.make<void>();
140150 const snapshots = yield* Ref.make<OrchestrationShellSnapshot>({
......@@ -154,7 +164,10 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options
154164 let uuid = 0;
155165 const dependencies = Layer.mergeAll(
156166 Layer.mock(ProjectionSnapshotQuery)({
157 getShellSnapshot: () => Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, null))),
167 getShellSnapshot: (readOptions) =>
168 (options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)).pipe(
169 Effect.tap(() => Queue.offer(reads, null)),
170 ),
158171 getSnapshotSequence: () =>
159172 Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))),
160173 getThreadShellById: (threadId) =>
......@@ -644,6 +657,113 @@ describe("ThreadPullRequestReactor", () => {
644657 ),
645658 );
646659
660 it.effect("discovers the same PRs from the unsettled read as from the full read", () =>
661 Effect.gen(function* () {
662 const sql = yield* SqlClient.SqlClient;
663 const query = yield* ProjectionSnapshotQuery;
664 yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
665 VALUES ('project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}),
666 ('dormant', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`;
667 yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, created_at, updated_at, archived_at, settled_override, settled_at)
668 VALUES
669 ('open', 'project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open', NULL, ${NOW}, ${NOW}, NULL, NULL, NULL),
670 ('resumed', 'project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'resumed', NULL, ${NOW}, ${NOW}, NULL, 'active', NULL),
671 ('linked', 'project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'linked', '{"projectId":"project","repository":"owner/repository","number":3,"url":"https://github.com/owner/repository/pull/3"}', ${NOW}, ${NOW}, NULL, NULL, NULL),
672 ('settled', 'project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'settled', '{"projectId":"project","repository":"owner/repository","number":4,"url":"https://github.com/owner/repository/pull/4"}', ${NOW}, ${NOW}, NULL, 'settled', ${NOW}),
673 ('backfill', 'project', 'Backfill', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'backfill', NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}),
674 ('imported', 'dormant', 'Imported', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}),
675 ('archived', 'project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'archived', NULL, ${NOW}, ${NOW}, ${NOW}, NULL, NULL)`;
676 const numbers = new Map([
677 ["open", 1],
678 ["resumed", 2],
679 ["linked", 3],
680 ["settled", 4],
681 ["backfill", 5],
682 ["archived", 6],
683 ]);
684
685 // Startup, then two periodic passes. The startup backfill lookup fails,
686 // so the first periodic pass retries it from the full read.
687 const discover = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) =>
688 Effect.gen(function* () {
689 const online = yield* Ref.make(false);
690 const reads: Array<ReadonlyArray<string>> = [];
691 const fixture = yield* makeHarness({
692 threads: [],
693 getShellSnapshot: (options) =>
694 read(options).pipe(
695 Effect.tap((snapshot) =>
696 Effect.sync(() => reads.push(snapshot.threads.map(({ id }) => id).toSorted())),
697 ),
698 ),
699 branchPullRequest: ({ cwd, branch }) =>
700 Effect.gen(function* () {
701 if (branch === "backfill" && !(yield* Ref.get(online))) {
702 return yield* new GitManagerError({
703 operation: "branchPullRequest",
704 cwd,
705 detail: "Offline",
706 });
707 }
708 const number = numbers.get(branch);
709 return number === undefined ? null : branchPullRequest(number);
710 }),
711 });
712 return yield* Effect.gen(function* () {
713 const reactor = yield* fixture.start();
714 const startupCalls = (yield* Ref.get(fixture.branchCalls)).length;
715 const startupCommands = (yield* Ref.get(fixture.commands)).length;
716 yield* Ref.set(online, true);
717 for (let pass = 0; pass < 2; pass++) {
718 yield* TestClock.adjust("1 minute");
719 yield* Queue.take(fixture.reads);
720 yield* reactor.drain;
721 }
722 return {
723 reads,
724 branchCalls: (yield* Ref.get(fixture.branchCalls))
725 .slice(startupCalls)
726 .map(({ branch }) => branch)
727 .toSorted(),
728 commands: (yield* Ref.get(fixture.commands))
729 .slice(startupCommands)
730 .map(
731 ({ threadId, branchPullRequest }) => `${threadId} ${branchPullRequest?.number}`,
732 )
733 .toSorted(),
734 };
735 }).pipe(Effect.provide(fixture.layer));
736 }).pipe(Effect.scoped);
737
738 const { reads: unsettledReads, ...unsettled } = yield* discover(query.getShellSnapshot);
739 const { reads: fullReads, ...full } = yield* discover(() => query.getShellSnapshot());
740 expect(unsettled).toEqual(full);
741 expect(unsettled.commands).toEqual([
742 "backfill 5",
743 "open 1",
744 "open 1",
745 "resumed 2",
746 "resumed 2",
747 ]);
748 // The last pass has no backfill left, so it reads no settled thread.
749 expect(fullReads.at(-1)).toContain("imported");
750 expect(unsettledReads.at(-1)).toEqual(["linked", "open", "resumed"]);
751 }).pipe(
752 Effect.provide(
753 OrchestrationProjectionSnapshotQueryLive.pipe(
754 Layer.provide(ThreadBackgroundLiveness.layer),
755 Layer.provide(ThreadPlanProgress.layer),
756 Layer.provide(
757 Layer.succeed(RepositoryIdentityResolver, {
758 resolve: () => Effect.succeed(project.repositoryIdentity),
759 }),
760 ),
761 Layer.provideMerge(SqlitePersistenceMemory),
762 ),
763 ),
764 ),
765 );
766
647767 it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () =>
648768 Effect.scoped(
649769 Effect.gen(function* () {
apps/server/src/orchestration/ThreadPullRequestReactor.ts+16-6
......@@ -74,7 +74,9 @@ export function pullRequestMatchesProject(
7474
7575/**
7676 * 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.
77 * thread reads that thread and the projects it names, not every thread. A
78 * sweep over all threads reads only unsettled threads, since both sweeps skip
79 * settled ones. Discovery's backfill does its own full read.
7880 */
7981export const readSweepSnapshot = (
8082 snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQueryShape,
......@@ -84,7 +86,7 @@ export const readSweepSnapshot = (
8486 ProjectionRepositoryError
8587> =>
8688 threadId === null
87 ? snapshots.getShellSnapshot()
89 ? snapshots.getShellSnapshot({ unsettledOnly: true })
8890 : Effect.gen(function* () {
8991 // Read the sequence first. The thread is then at least this new, so a
9092 // command guarded by the sequence is rejected rather than missing a change.
......@@ -129,7 +131,11 @@ export const make = Effect.gen(function* () {
129131 const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* (
130132 request: RefreshRequest,
131133 ) {
132 const snapshot = yield* readSweepSnapshot(snapshots, request.threadId);
134 // Backfill looks up settled threads, so its passes read every thread.
135 const snapshot =
136 request.threadId === null && (request.backfill || pendingBackfill.size > 0)
137 ? yield* snapshots.getShellSnapshot()
138 : yield* readSweepSnapshot(snapshots, request.threadId);
133139 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
134140 if (request.backfill) {
135141 for (const thread of snapshot.threads) {
......@@ -141,11 +147,15 @@ export const make = Effect.gen(function* () {
141147 }
142148 }
143149 }
144 // A single-thread read only shows whether its own thread is gone.
145 const threadIds = new Set(snapshot.threads.map((thread) => thread.id));
150 // A single-thread read only shows whether its own thread is gone. A thread
151 // with no branch has nothing to look up, and its entry would keep every
152 // periodic pass on the full read.
153 const branchThreadIds = new Set(
154 snapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id),
155 );
146156 const checkedIds = request.threadId === null ? pendingBackfill.keys() : [request.threadId];
147157 for (const threadId of checkedIds) {
148 if (!threadIds.has(threadId)) pendingBackfill.delete(threadId);
158 if (!branchThreadIds.has(threadId)) pendingBackfill.delete(threadId);
149159 }
150160 const threads = snapshot.threads.filter(
151161 (thread) =>
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+96-3
......@@ -30,8 +30,11 @@ import * as Queue from "effect/Queue";
3030import * as Ref from "effect/Ref";
3131import * as Stream from "effect/Stream";
3232import { TestClock } from "effect/testing";
33import * as SqlClient from "effect/unstable/sql/SqlClient";
3334
3435import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts";
36import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
37import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts";
3538import {
3639 PullRequestService,
3740 type PullRequestMergeEvent,
......@@ -39,11 +42,17 @@ import {
3942import { ServerActivation } from "../serverActivation.ts";
4043import { ServerSettingsService } from "../serverSettings.ts";
4144import { OrchestrationCommandInvariantError } from "./Errors.ts";
45import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
4246import {
4347 OrchestrationEngineService,
4448 type OrchestrationEngineShape,
4549} from "./Services/OrchestrationEngine.ts";
46import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
50import {
51 ProjectionSnapshotQuery,
52 type ProjectionSnapshotQueryShape,
53} from "./Services/ProjectionSnapshotQuery.ts";
54import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
55import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
4756import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
4857import * as NodeServices from "@effect/platform-node/NodeServices";
4958import * as Path from "effect/Path";
......@@ -169,6 +178,8 @@ function makeBranchPullRequest(
169178
170179interface HarnessOptions {
171180 readonly snapshot: OrchestrationShellSnapshot;
181 /** Serve full sweep reads from this instead of `snapshot`. */
182 readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"];
172183 readonly settings?: ServerSettings;
173184 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
174185 readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
......@@ -256,10 +267,10 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
256267
257268 const dependencies = Layer.mergeAll(
258269 Layer.mock(ProjectionSnapshotQuery)({
259 getShellSnapshot: () =>
270 getShellSnapshot: (readOptions) =>
260271 Ref.update(snapshotReadCount, (count) => count + 1).pipe(
261272 Effect.andThen(Queue.offer(snapshotReads, null)),
262 Effect.andThen(Ref.get(snapshots)),
273 Effect.andThen(options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)),
263274 ),
264275 getSnapshotSequence: () =>
265276 Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))),
......@@ -1437,6 +1448,88 @@ describe("ThreadSettlementReactor", () => {
14371448 }),
14381449 ),
14391450 );
1451
1452 it.effect("settles the same threads from the unsettled read as from the full read", () =>
1453 Effect.gen(function* () {
1454 yield* TestClock.setTime(Date.parse(NOW));
1455 const sql = yield* SqlClient.SqlClient;
1456 const query = yield* ProjectionSnapshotQuery;
1457 yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
1458 VALUES ('settlement-project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}),
1459 ('linked-settlement-project', 'Linked', '/workspace/linked', '[]', ${NOW}, ${NOW}),
1460 ('dormant-project', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`;
1461 yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, latest_user_message_at, created_at, updated_at, archived_at, settled_override, settled_at)
1462 VALUES
1463 ('idle', 'settlement-project', 'Idle', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL),
1464 ('merged', 'settlement-project', 'Merged', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature',
1465 '{"projectId":"linked-settlement-project","repository":"owner/repository","number":42,"url":"https://example.test/owner/repository/pull/42"}',
1466 '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL),
1467 ('linked', 'settlement-project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL),
1468 ('open', 'settlement-project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open-feature', NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL),
1469 ('resumed', 'settlement-project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'active', NULL),
1470 ('settled', 'dormant-project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'done', NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'settled', '2026-08-21T00:00:00.000Z'),
1471 ('archived', 'settlement-project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, ${NOW}, NULL, NULL)`;
1472 yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at, snapshot_json)
1473 VALUES ('linked', 'example.test', 'owner/repository', 7, 'https://example.test/owner/repository/pull/7', 'manual', ${NOW},
1474 '{"state":"merged","title":"Review","headBranch":"linked","baseBranch":"main","isDraft":false,"updatedAt":"2026-08-28T12:00:00.000Z","syncedAt":"2026-08-28T12:00:00.000Z","mergedAt":"2026-08-28T12:00:00.000Z","closedAt":null}')`;
1475
1476 const sweep = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) =>
1477 Effect.gen(function* () {
1478 const readThreadIds: Array<string> = [];
1479 const fixture = yield* makeHarness({
1480 snapshot: makeSnapshot([]),
1481 getShellSnapshot: (options) =>
1482 read(options).pipe(
1483 Effect.tap((snapshot) =>
1484 Effect.sync(() => readThreadIds.push(...snapshot.threads.map(({ id }) => id))),
1485 ),
1486 ),
1487 branchPullRequest: ({ branch }) =>
1488 Effect.succeed(branch === "open-feature" ? makeBranchPullRequest("open") : null),
1489 pullRequestSummary: (input) =>
1490 Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
1491 });
1492 return yield* Effect.gen(function* () {
1493 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
1494 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
1495 return {
1496 readThreadIds: readThreadIds.toSorted(),
1497 commands: (yield* Ref.get(fixture.commands))
1498 .map(({ threadId, settledAt }) => `${threadId} ${settledAt}`)
1499 .toSorted(),
1500 branchCalls: (yield* Ref.get(fixture.branchCalls))
1501 .map(({ branch }) => branch)
1502 .toSorted(),
1503 summaryCalls: yield* Ref.get(fixture.summaryCalls),
1504 };
1505 }).pipe(Effect.provide(fixture.layer));
1506 }).pipe(Effect.scoped);
1507
1508 const { readThreadIds: unsettledReads, ...unsettled } = yield* sweep(query.getShellSnapshot);
1509 const { readThreadIds: fullReads, ...full } = yield* sweep(() => query.getShellSnapshot());
1510 assert.deepStrictEqual(unsettled, full);
1511 // A settle for inactivity, for a synced merged link, and for a saved
1512 // branch PR whose project only that PR names.
1513 assert.deepStrictEqual(unsettled.commands, [
1514 "idle 2026-08-20T00:00:00.000Z",
1515 "linked 2026-08-27T00:00:00.000Z",
1516 "merged 2026-08-27T00:00:00.000Z",
1517 ]);
1518 assert.deepStrictEqual(fullReads, [...unsettledReads, "settled"].toSorted());
1519 assert.deepStrictEqual(unsettledReads, ["idle", "linked", "merged", "open", "resumed"]);
1520 }).pipe(
1521 Effect.provide(
1522 OrchestrationProjectionSnapshotQueryLive.pipe(
1523 Layer.provide(ThreadBackgroundLiveness.layer),
1524 Layer.provide(ThreadPlanProgress.layer),
1525 Layer.provide(
1526 Layer.succeed(RepositoryIdentityResolver, { resolve: () => Effect.succeed(null) }),
1527 ),
1528 Layer.provideMerge(SqlitePersistenceMemory),
1529 ),
1530 ),
1531 ),
1532 );
14401533});
14411534
14421535describe("storage cleanup", () => {