History

fix: reconcile main's round-18 features after the rebase

Adopts the round's main features into the v2 architecture: the #9023 media rework (streamed videos, media-file assets, protocol-relative links), #9098 shared live-activity row folded into the v2 working and thinking rows, the #9084/#9078 Claude model catalog for v2 consumers, a native #9005 OpenCode child-session abort in the v2 adapter, #9013's landed LegendList patch, and per-environment sidebar provider entries. For #8600 the server-side pieces land, but auto-settle evaluation stays client-side (reading the new server-owned settings) until the v2 orchestrator grows its own settlement reactor; main's v1-only reactor and coalescer additions are dropped with the rest of the v1 path.

Julius Marminge committed 2 months ago

Commit 7b13bca1 parent 786a25847 files · +1855 −2616Browse files

Showing the changes to apps/server/src/orchestration (9 of 47 files). Show all

Unified · Split

9 files
  1. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +0 −33
  2. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +10 −23
  3. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +70 −258
  4. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +0 −4
  5. apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts +0 −250
  6. apps/server/src/orchestration/ThreadLiveEventCoalescer.ts +0 −241
  7. apps/server/src/orchestration/ThreadSettlementReactor.test.ts +0 −858
  8. apps/server/src/orchestration/ThreadSettlementReactor.ts +0 −249
  9. apps/server/src/orchestration/decider.ts +1 −0
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+0−33
@@ -166,42 +166,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 165 unmodified lines
166166 });
167167 }
168168
169− if (
170− envelope.command.type === "thread.auto-settle" &&
171− (yield* eventStore.hasEventAfter({
172− aggregateKind: "thread",
173− aggregateId: envelope.command.threadId,
174− sequenceExclusive: envelope.command.snapshotSequence,
175− }))
176− ) {
177− return yield* new OrchestrationCommandInvariantError({
178− commandType: envelope.command.type,
179− detail: `thread ${envelope.command.threadId} changed before automatic settlement`,
180− });
181− }
182−
183− if (
184− envelope.command.type === "thread.auto-settle" &&
185− threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null
186− ) {
187− return yield* new OrchestrationCommandInvariantError({
188− commandType: envelope.command.type,
189− detail: `thread ${envelope.command.threadId} has live background work`,
190− });
191− }
192−
193− // Command snapshots omit activities at startup and cap them while running.
194− // Read this request's durable state before deciding how to send the answer.
195− const userInputActivity =
196− envelope.command.type === "thread.user-input.respond"
197− ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command)
198− : Option.none();
199169 const eventBase = yield* decideOrchestrationCommand({
200170 command: envelope.command,
201171 readModel: commandReadModel,
202− ...(Option.isSome(userInputActivity)
203− ? { userInputActivity: userInputActivity.value }
204− : {}),
205172 }).pipe(
206173 Effect.provideService(Crypto.Crypto, crypto),
207174 Effect.mapError((cause) =>
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+10−23
@@ -1018,34 +1018,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1017 unmodified lines
10181018 return;
10191019
10201020 case "thread.message-sent": {
1021− if (event.payload.streaming) {
1022− const attachments =
1023− event.payload.attachments !== undefined
1024− ? yield* materializeAttachmentsForProjection({
1025− attachments: event.payload.attachments,
1026− })
1027− : undefined;
1028− yield* projectionThreadMessageRepository.appendStreaming({
1029− messageId: event.payload.messageId,
1030− threadId: event.payload.threadId,
1031− turnId: event.payload.turnId,
1032− role: event.payload.role,
1033− text: event.payload.text,
1034− ...(attachments !== undefined ? { attachments: [...attachments] } : {}),
1035− createdAt: event.payload.createdAt,
1036− updatedAt: event.payload.updatedAt,
1037− });
1038− return;
1039− }
1040−
10411021 const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({
10421022 messageId: event.payload.messageId,
10431023 });
10441024 const previousMessage = Option.getOrUndefined(existingMessage);
10451025 const nextText = Option.match(existingMessage, {
10461026 onNone: () => event.payload.text,
1047− onSome: (message) =>
1048− event.payload.text.length === 0 ? message.text : event.payload.text,
1027+ onSome: (message) => {
1028+ if (event.payload.streaming) {
1029+ return `${message.text}${event.payload.text}`;
1030+ }
1031+ if (event.payload.text.length === 0) {
1032+ return message.text;
1033+ }
1034+ return event.payload.text;
1035+ },
10491036 });
10501037 const nextAttachments =
10511038 event.payload.attachments !== undefined
@@ -1060,7 +1047,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 8 unmodified lines
10601047 role: event.payload.role,
10611048 text: nextText,
10621049 ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
1063− isStreaming: false,
1050+ isStreaming: event.payload.streaming,
10641051 createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
10651052 updatedAt: event.payload.updatedAt,
10661053 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+70−258
@@ -77,9 +77,6 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); · 76 unmodified lines
7777 // activity window. Applying the limit in SQL avoids decoding an unbounded
7878 // payload_json set before the projector can enforce that invariant.
7979 const THREAD_DETAIL_ACTIVITY_LIMIT = 500;
80−// Snapshot payloads are decoded and projected in small sequential batches so
81−// one client read does not retain the raw payloads for the full activity window.
82−const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;
8380 const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
8481 Struct.assign({
8582 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
@@ -104,9 +101,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( · 18 unmodified lines
104101 sequence: Schema.NullOr(NonNegativeInt),
105102 }),
106103 );
107−const ProjectionThreadActivityIdRowSchema = Schema.Struct({
108− activityId: ProjectionThreadActivity.fields.activityId,
109−});
110104 const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
111105 const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
112106 id: ThreadId,
@@ -166,9 +160,6 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ · 53 unmodified lines
166160 threadId: ThreadId,
167161 activityKinds: Schema.Array(Schema.String),
168162 });
169−const ThreadActivityIdsLookupInput = Schema.Struct({
170− activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId),
171−});
172163 // Windowed reads order turns by the stable keyset (anchor, turn key), where
173164 // anchor is requested_at and turn key is
174165 // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the
@@ -372,21 +363,6 @@ function mapProposedPlanRow( · 197 unmodified lines
372363 };
373364 }
374365
375−function mapThreadActivityRow(
376− row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,
377−): OrchestrationThreadActivity {
378− return {
379− id: row.activityId,
380− tone: row.tone,
381− kind: row.kind,
382− summary: row.summary,
383− payload: row.payload,
384− turnId: row.turnId,
385− createdAt: row.createdAt,
386− ...(row.sequence !== null ? { sequence: row.sequence } : {}),
387− };
388−}
389−
390366 function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
391367 return (cause: unknown): ProjectionRepositoryError =>
392368 Schema.isSchemaError(cause)
@@ -1187,44 +1163,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 794 unmodified lines
11871163 `,
11881164 });
11891165
1190− const listThreadActivityIdsByThread = SqlSchema.findAll({
1191− Request: ThreadIdLookupInput,
1192− Result: ProjectionThreadActivityIdRowSchema,
1193− execute: ({ threadId }) =>
1194− sql`
1195− SELECT activity_id AS "activityId"
1196− FROM projection_thread_activities
1197− WHERE thread_id = ${threadId}
1198− ORDER BY
1199− sequence DESC,
1200− created_at DESC,
1201− activity_id DESC
1202− LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT}
1203− `,
1204− });
1205−
1206− const listThreadActivityRowsByIds = SqlSchema.findAll({
1207− Request: ThreadActivityIdsLookupInput,
1208− Result: ProjectionThreadActivityDbRowSchema,
1209− execute: ({ activityIds }) =>
1210− sql`
1211− SELECT
1212− activity_id AS "activityId",
1213− thread_id AS "threadId",
1214− turn_id AS "turnId",
1215− tone,
1216− kind,
1217− summary,
1218− payload_json AS "payload",
1219− sequence,
1220− created_at AS "createdAt"
1221− FROM projection_thread_activities
1222− -- The selectors already scoped these globally unique ids to the
1223− -- thread inside this transaction. Keep this as a primary-key lookup.
1224− WHERE ${sql.in("activity_id", activityIds)}
1225− `,
1226− });
1227−
12281166 const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({
12291167 Request: ThreadActivityKindsLookupInput,
12301168 Result: ProjectionThreadActivityDbRowSchema,
@@ -1475,8 +1413,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 244 unmodified lines
14751413 `,
14761414 });
14771415
1478− const pinnedThreadActivityIdsCte = (threadId: string) => sql`
1479−pending_approval_requests AS (
1416+ // Blocking request payloads must remain available even if they predate the
1417+ // recent activity window. Each CTE returns at most one unresolved row per
1418+ // request, so the merge below stays bounded by actionable work.
1419+ const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
1420+ Request: ThreadIdLookupInput,
1421+ Result: ProjectionThreadActivityDbRowSchema,
1422+ execute: ({ threadId }) =>
1423+ sql`
1424+ WITH pending_approval_requests AS (
14801425 SELECT request_id, thread_id
14811426 FROM projection_pending_approvals
14821427 WHERE thread_id = ${threadId}
@@ -1540,17 +1485,6 @@ pending_approval_requests AS ( · 57 unmodified lines
15401485 WHERE request_order = 1
15411486 AND kind = 'user-input.requested'
15421487 )
1543− `;
1544−
1545− // Blocking request payloads must remain available even if they predate the
1546− // recent activity window. Each CTE returns at most one unresolved row per
1547− // request, so the merge below stays bounded by actionable work.
1548− const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
1549− Request: ThreadIdLookupInput,
1550− Result: ProjectionThreadActivityDbRowSchema,
1551− execute: ({ threadId }) =>
1552− sql`
1553− WITH ${pinnedThreadActivityIdsCte(threadId)}
15541488 SELECT
15551489 activity.activity_id AS "activityId",
15561490 activity.thread_id AS "threadId",
@@ -1568,17 +1502,6 @@ pending_approval_requests AS ( · 11 unmodified lines
15681502 `,
15691503 });
15701504
1571− const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({
1572− Request: ThreadIdLookupInput,
1573− Result: ProjectionThreadActivityIdRowSchema,
1574− execute: ({ threadId }) =>
1575− sql`
1576− WITH ${pinnedThreadActivityIdsCte(threadId)}
1577− SELECT activity_id AS "activityId"
1578− FROM pinned_activity_ids
1579− `,
1580− });
1581−
15821505 const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({
15831506 Request: ThreadTurnRangeLookupInput,
15841507 Result: ProjectionThreadActivityDbRowSchema,
@@ -1646,48 +1569,6 @@ pending_approval_requests AS ( · 61 unmodified lines
16461569 `,
16471570 });
16481571
1649− const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({
1650− Request: ThreadTurnRangeLookupInput,
1651− Result: ProjectionThreadActivityIdRowSchema,
1652− execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) =>
1653− sql`
1654− SELECT activity_id AS "activityId"
1655− FROM projection_thread_activities
1656− WHERE thread_id = ${threadId}
1657− AND (
1658− turn_id IN (
1659− SELECT turn_id FROM projection_turns
1660− WHERE thread_id = ${threadId}
1661− AND turn_id IS NOT NULL
1662− AND (
1663− requested_at > ${minAnchorAt}
1664− OR (
1665− requested_at = ${minAnchorAt}
1666− AND turn_id >= ${minTurnKey}
1667− )
1668− )
1669− AND (
1670− requested_at < ${beforeAnchorAt}
1671− OR (
1672− requested_at = ${beforeAnchorAt}
1673− AND turn_id < ${beforeTurnKey}
1674− )
1675− )
1676− )
1677− OR (
1678− turn_id IS NULL
1679− AND created_at >= ${minAnchorAt}
1680− AND created_at < ${beforeAnchorAt}
1681− )
1682− )
1683− ORDER BY
1684− sequence DESC,
1685− created_at DESC,
1686− activity_id DESC
1687− LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT}
1688− `,
1689− });
1690−
16911572 const getFullThreadDiffContextRow = SqlSchema.findOneOption({
16921573 Request: FullThreadDiffContextLookupInput,
16931574 Result: ProjectionFullThreadDiffContextRowSchema,
@@ -2832,136 +2713,49 @@ pending_approval_requests AS ( · 1138 unmodified lines
28322713 readonly beforeTurnKey: string;
28332714 }
28342715
2835− type ThreadDetailActivityRead =
2836− | {
2837− readonly mode: "raw";
2838− readonly query?: ProjectionThreadDetailQuery;
2839− }
2840− | {
2841− readonly mode: "client";
2842− };
2843−
2844− const listProjectedThreadActivities = Effect.fn(
2845− "ProjectionSnapshotQuery.listProjectedThreadActivities",
2846− )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) {
2847− const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([
2848− (bounds === undefined
2849− ? listThreadActivityIdsByThread({ threadId })
2850− : listThreadActivityIdsByThreadWindow({ threadId, ...bounds })
2716+ const getThreadDetailByIdBounded = (
2717+ threadId: ThreadId,
2718+ bounds: ThreadDetailBounds | undefined,
2719+ query: ProjectionThreadDetailQuery | undefined = undefined,
2720+ ) =>
2721+ Effect.gen(function* () {
2722+ const activityRowsEffect = (
2723+ query?.activityKinds === undefined
2724+ ? bounds === undefined
2725+ ? listThreadActivityRowsByThread({ threadId })
2726+ : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2727+ : query.activityKinds.length === 0
2728+ ? Effect.succeed([])
2729+ : listThreadActivityRowsByThreadAndKinds({
2730+ threadId,
2731+ activityKinds: query.activityKinds,
2732+ })
28512733 ).pipe(
28522734 Effect.mapError(
28532735 toPersistenceSqlOrDecodeError(
2854− "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query",
2855− "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows",
2736+ "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2737+ "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
28562738 ),
28572739 ),
2858− ),
2859− listPinnedThreadActivityIdsByThread({ threadId }).pipe(
2860− Effect.mapError(
2861− toPersistenceSqlOrDecodeError(
2862− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query",
2863− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows",
2864− ),
2865− ),
2866− ),
2867− ]);
2868− const activityIds = [
2869− ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)),
2870− ];
2871− const activities: OrchestrationThreadActivity[] = [];
2872−
2873− for (
2874− let offset = 0;
2875− offset < activityIds.length;
2876− offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE
2877− ) {
2878− const batchIds = activityIds.slice(
2879− offset,
2880− offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE,
28812740 );
2882− const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe(
2883− Effect.mapError(
2884− toPersistenceSqlOrDecodeError(
2885− "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query",
2886− "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows",
2887− ),
2888− ),
2889− );
2890− for (const row of batchRows) {
2891− activities.push(projectActivityPayload(mapThreadActivityRow(row)));
2892− }
2893− }
2894−
2895− return activities.toSorted(
2896− (left, right) =>
2897− (left.sequence ?? -1) - (right.sequence ?? -1) ||
2898− left.createdAt.localeCompare(right.createdAt) ||
2899− left.id.localeCompare(right.id),
2900− );
2901− });
2902−
2903− const getThreadDetailByIdBounded = (
2904− threadId: ThreadId,
2905− bounds: ThreadDetailBounds | undefined,
2906− activityRead: ThreadDetailActivityRead = { mode: "raw" },
2907− ) =>
2908− Effect.gen(function* () {
2909− const activitiesEffect =
2910− activityRead.mode === "client"
2911− ? listProjectedThreadActivities(threadId, bounds)
2912− : Effect.all([
2913− (activityRead.query?.activityKinds === undefined
2914− ? bounds === undefined
2915− ? listThreadActivityRowsByThread({ threadId })
2916− : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2917− : activityRead.query.activityKinds.length === 0
2918− ? Effect.succeed([])
2919− : listThreadActivityRowsByThreadAndKinds({
2920− threadId,
2921− activityKinds: activityRead.query.activityKinds,
2922− })
2923− ).pipe(
2924− Effect.mapError(
2925− toPersistenceSqlOrDecodeError(
2926− "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2927− "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
2928− ),
2741+ const pinnedActivityRowsEffect =
2742+ query?.activityKinds === undefined
2743+ ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2744+ Effect.mapError(
2745+ toPersistenceSqlOrDecodeError(
2746+ "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2747+ "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
29292748 ),
29302749 ),
2931− activityRead.query?.activityKinds === undefined
2932− ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2933− Effect.mapError(
2934− toPersistenceSqlOrDecodeError(
2935− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2936− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
2937− ),
2938− ),
2939− )
2940− : Effect.succeed([]),
2941− ]).pipe(
2942− Effect.map(([activityRows, pinnedActivityRows]) =>
2943− [
2944− ...new Map(
2945− [...activityRows, ...pinnedActivityRows].map(
2946− (row) => [row.activityId, row] as const,
2947− ),
2948− ).values(),
2949− ]
2950− .toSorted(
2951− (left, right) =>
2952− (left.sequence ?? -1) - (right.sequence ?? -1) ||
2953− left.createdAt.localeCompare(right.createdAt) ||
2954− left.activityId.localeCompare(right.activityId),
2955− )
2956− .map(mapThreadActivityRow),
2957− ),
2958− );
2750+ )
2751+ : Effect.succeed([]);
29592752
29602753 const [
29612754 threadRow,
29622755 messageRows,
29632756 proposedPlanRows,
2964− activities,
2757+ activityRows,
2758+ pinnedActivityRows,
29652759 checkpointRows,
29662760 latestTurnRow,
29672761 sessionRow,
@@ -2993,7 +2787,8 @@ pending_approval_requests AS ( · 25 unmodified lines
29932787 ),
29942788 ),
29952789 ),
2996− activitiesEffect,
2790+ activityRowsEffect,
2791+ pinnedActivityRowsEffect,
29972792 listCheckpointRowsByThread({ threadId }).pipe(
29982793 Effect.mapError(
29992794 toPersistenceSqlOrDecodeError(
@@ -3024,6 +2819,17 @@ pending_approval_requests AS ( · 24 unmodified lines
30242819 return Option.none<OrchestrationThread>();
30252820 }
30262821
2822+ const selectedActivityRows = [
2823+ ...new Map(
2824+ [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const),
2825+ ).values(),
2826+ ].toSorted(
2827+ (left, right) =>
2828+ (left.sequence ?? -1) - (right.sequence ?? -1) ||
2829+ left.createdAt.localeCompare(right.createdAt) ||
2830+ left.activityId.localeCompare(right.activityId),
2831+ );
2832+
30272833 const thread = {
30282834 id: threadRow.value.threadId,
30292835 projectId: threadRow.value.projectId,
@@ -3061,7 +2867,21 @@ pending_approval_requests AS ( · 31 unmodified lines
30612867 return message;
30622868 }),
30632869 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),
3064− activities,
2870+ activities: selectedActivityRows.map((row) => {
2871+ const activity = {
2872+ id: row.activityId,
2873+ tone: row.tone,
2874+ kind: row.kind,
2875+ summary: row.summary,
2876+ payload: row.payload,
2877+ turnId: row.turnId,
2878+ createdAt: row.createdAt,
2879+ };
2880+ if (row.sequence !== null) {
2881+ return Object.assign(activity, { sequence: row.sequence });
2882+ }
2883+ return activity;
2884+ }),
30652885 checkpoints: checkpointRows.map((row) => ({
30662886 turnId: row.turnId,
30672887 checkpointTurnCount: row.checkpointTurnCount,
@@ -3086,11 +2906,7 @@ pending_approval_requests AS ( · 18 unmodified lines
30862906 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (
30872907 threadId,
30882908 query,
3089− ) =>
3090− getThreadDetailByIdBounded(threadId, undefined, {
3091− mode: "raw",
3092− ...(query === undefined ? {} : { query }),
3093− });
2909+ ) => getThreadDetailByIdBounded(threadId, undefined, query);
30942910
30952911 // Bounds pathological fan-out: one user turn that spawned hundreds of
30962912 // subagent turns still pages in bounded chunks, at the cost of splitting the
@@ -3114,9 +2930,7 @@ pending_approval_requests AS ( · 17 unmodified lines
31142930 .withTransaction(
31152931 Effect.gen(function* () {
31162932 if (window?.turnLimit === undefined) {
3117− const thread = yield* getThreadDetailByIdBounded(threadId, undefined, {
3118− mode: "client",
3119− });
2933+ const thread = yield* getThreadDetailById(threadId);
31202934 if (Option.isNone(thread)) {
31212935 return Option.none<OrchestrationThreadDetailSnapshot>();
31222936 }
@@ -3169,9 +2983,7 @@ pending_approval_requests AS ( · 46 unmodified lines
31692983 ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" }
31702984 : undefined;
31712985
3172− const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, {
3173− mode: "client",
3174− });
2986+ const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds);
31752987 if (Option.isNone(thread)) {
31762988 return Option.none<OrchestrationThreadDetailSnapshot>();
31772989 }
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+0−4
@@ -227,10 +227,6 @@ export interface ProjectionSnapshotQueryShape { · 226 unmodified lines
227227 * response carries `page` metadata (see `OrchestrationThreadDetailWindow`).
228228 * Without a window the full thread is returned with no `page` field —
229229 * pagination is strictly opt-in.
230− *
231− * Activity payloads are projected for clients as they are read in small
232− * sequential batches. Callers still apply the full snapshot projector for
233− * collection-level activity pruning.
234230 */
235231 readonly getThreadDetailSnapshot: (
236232 threadId: ThreadId,
apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts+0−250
@@ -1,250 +0,0 @@
1−import {
2− EventId,
3− MessageId,
4− ThreadId,
5− TurnId,
6− OrchestrationEvent,
7− type OrchestrationThreadActivity,
8−} from "@t3tools/contracts";
9−import { it } from "@effect/vitest";
10−import * as Clock from "effect/Clock";
11−import * as Effect from "effect/Effect";
12−import * as Schema from "effect/Schema";
13−import * as Stream from "effect/Stream";
14−import * as TestClock from "effect/testing/TestClock";
15−import { describe, expect } from "vite-plus/test";
16−
17−import {
18− coalesceLiveToolUpdatedEvents,
19− makeThreadLiveEventCoalescer,
20−} from "./ThreadLiveEventCoalescer.ts";
21−
22−const threadId = ThreadId.make("thread-coalescer-test");
23−const turnId = TurnId.make("turn-coalescer-test");
24−const encodeEvent = Schema.encodeSync(Schema.fromJsonString(OrchestrationEvent));
25−
26−function makeToolActivity(
27− sequence: number,
28− options: {
29− readonly kind?: "tool.updated" | "tool.completed";
30− readonly toolCallId?: string;
31− readonly turnId?: TurnId;
32− } = {},
33−): OrchestrationEvent {
34− const {
35− kind = "tool.updated",
36− toolCallId = "call-edit",
37− turnId: activityTurnId = turnId,
38− } = options;
39− const activity: OrchestrationThreadActivity = {
40− id: EventId.make(`activity-${sequence}`),
41− tone: "tool",
42− kind,
43− summary: "Editing app.ts",
44− payload: {
45− itemType: "file_change",
46− title: "Editing app.ts",
47− data: toolCallId ? { toolCallId } : {},
48− },
49− turnId: activityTurnId,
50− createdAt: "2026-01-01T00:00:01.000Z",
51− };
52− return {
53− sequence,
54− eventId: EventId.make(`event-${sequence}`),
55− aggregateKind: "thread",
56− aggregateId: threadId,
57− occurredAt: "2026-01-01T00:00:01.000Z",
58− commandId: null,
59− causationEventId: null,
60− correlationId: null,
61− metadata: {},
62− type: "thread.activity-appended",
63− payload: { threadId, activity },
64− };
65−}
66−
67−function makeMessage(sequence: number, text = "Still working"): OrchestrationEvent {
68− return {
69− sequence,
70− eventId: EventId.make(`event-${sequence}`),
71− aggregateKind: "thread",
72− aggregateId: threadId,
73− occurredAt: "2026-01-01T00:00:02.000Z",
74− commandId: null,
75− causationEventId: null,
76− correlationId: null,
77− metadata: {},
78− type: "thread.message-sent",
79− payload: {
80− threadId,
81− messageId: MessageId.make(`message-${sequence}`),
82− role: "assistant",
83− text,
84− turnId,
85− streaming: false,
86− createdAt: "2026-01-01T00:00:02.000Z",
87− updatedAt: "2026-01-01T00:00:02.000Z",
88− },
89− };
90−}
91−
92−describe("ThreadLiveEventCoalescer", () => {
93− it("coalesces only calls with a stable toolCallId", () => {
94− const events = [
95− makeToolActivity(1, { toolCallId: "call-a" }),
96− makeToolActivity(2, { toolCallId: "call-b" }),
97− makeToolActivity(3, { toolCallId: "call-a" }),
98− ];
99−
100− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]);
101− });
102−
103− it("preserves parallel same-label calls without a stable toolCallId", () => {
104− const events = [
105− makeToolActivity(1, { toolCallId: "" }),
106− makeToolActivity(2, { toolCallId: "" }),
107− makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }),
108− ];
109−
110− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]);
111− });
112−
113− it("does not coalesce stable tool calls across turns", () => {
114− const events = [
115− makeToolActivity(1, { turnId: TurnId.make("turn-old") }),
116− makeToolActivity(2, { turnId: TurnId.make("turn-new") }),
117− ];
118−
119− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]);
120− });
121−
122− it("flushes a stable update run before a completion boundary", () => {
123− const events = [
124− makeToolActivity(1),
125− makeToolActivity(2),
126− makeToolActivity(3, { kind: "tool.completed" }),
127− makeToolActivity(4),
128− ];
129−
130− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]);
131− });
132−
133− it.effect("flushes pending tool updates as soon as an unrelated event arrives", () =>
134− Effect.scoped(
135− Effect.gen(function* () {
136− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" });
137− const startedAt = yield* Clock.currentTimeMillis;
138− yield* Effect.forEach(
139− Array.from({ length: 10 }, (_, index) => index + 2),
140− (sequence) => coalescer.offer({ kind: "event", event: makeToolActivity(sequence) }),
141− { discard: true },
142− );
143− yield* coalescer.offer({ kind: "event", event: makeMessage(12) });
144−
145− expect(yield* Clock.currentTimeMillis).toBe(startedAt);
146− expect(
147− (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) =>
148− item.kind === "event" ? item.event.sequence : item.kind,
149− ),
150− ).toEqual([11, 12]);
151− }),
152− ).pipe(Effect.provide(TestClock.layer())),
153− );
154−
155− it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () =>
156− Effect.scoped(
157− Effect.gen(function* () {
158− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" });
159− const startedAt = yield* Clock.currentTimeMillis;
160− yield* coalescer.offer({ kind: "event", event: makeToolActivity(2) });
161− yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) });
162− yield* coalescer.offer({ kind: "synchronized" });
163−
164− expect(yield* Clock.currentTimeMillis).toBe(startedAt);
165− expect(
166− (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) =>
167− item.kind === "event" ? item.event.sequence : item.kind,
168− ),
169− ).toEqual([3, "synchronized"]);
170− }),
171− ).pipe(Effect.provide(TestClock.layer())),
172− );
173−
174− it.effect("fails and clears pending updates when their serialized payload fills the budget", () =>
175− Effect.scoped(
176− Effect.gen(function* () {
177− const first = makeToolActivity(1);
178− const coalescer = yield* makeThreadLiveEventCoalescer({
179− coalesceWindow: "500 millis",
180− maxSerializedBytes: Buffer.byteLength(encodeEvent(first)),
181− });
182− yield* coalescer.offer({ kind: "event", event: first });
183− expect(yield* coalescer.usage).toEqual({
184− retainedItems: 1,
185− retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)),
186− });
187−
188− const overflow = yield* coalescer
189− .offer({ kind: "event", event: makeToolActivity(2) })
190− .pipe(Effect.result);
191− expect(overflow._tag).toBe("Failure");
192− yield* coalescer.closed;
193− expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 });
194− const marker = yield* coalescer.offer({ kind: "synchronized" }).pipe(Effect.result);
195− expect(marker._tag).toBe("Failure");
196− const delivered = yield* coalescer.stream.pipe(Stream.runCollect, Effect.result);
197− expect(delivered._tag).toBe("Failure");
198− }),
199− ),
200− );
201−
202− it.effect("keeps the flush timer alive when an offer's shorter scope closes", () =>
203− Effect.scoped(
204− Effect.gen(function* () {
205− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "50 millis" });
206− yield* Effect.scoped(coalescer.offer({ kind: "event", event: makeToolActivity(1) }));
207− yield* TestClock.adjust("50 millis");
208− const items = yield* coalescer.stream.pipe(Stream.take(1), Stream.runCollect);
209− expect(
210− items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)),
211− ).toEqual([1]);
212− }),
213− ),
214− );
215−
216− it.effect("keeps an unacknowledged batch charged and clears later events on overflow", () =>
217− Effect.scoped(
218− Effect.gen(function* () {
219− const coalescer = yield* makeThreadLiveEventCoalescer({ maxItems: 3 });
220− const first = makeMessage(1, "é".repeat(1_024));
221− yield* coalescer.offer({ kind: "event", event: first });
222−
223− yield* Effect.scoped(
224− Effect.gen(function* () {
225− const pull = yield* Stream.toPull(coalescer.stream);
226− const batch = yield* pull;
227− expect(
228− batch.map((item) => (item.kind === "event" ? item.event.sequence : null)),
229− ).toEqual([1]);
230− yield* coalescer.offer({ kind: "event", event: makeMessage(2) });
231− yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) });
232− expect((yield* coalescer.usage).retainedItems).toBe(3);
233−
234− const overflow = yield* coalescer
235− .offer({ kind: "event", event: makeToolActivity(4, { kind: "tool.completed" }) })
236− .pipe(Effect.result);
237− expect(overflow._tag).toBe("Failure");
238− // Do not pull or acknowledge the batch. Cleanup must still finish.
239− yield* coalescer.closed;
240− expect(yield* coalescer.usage).toEqual({
241− retainedItems: 1,
242− retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)),
243− });
244− }),
245− );
246− expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 });
247− }),
248− ),
249− );
250−});
apps/server/src/orchestration/ThreadLiveEventCoalescer.ts+0−241
@@ -1,241 +0,0 @@
1−import type {
2− OrchestrationEvent,
3− OrchestrationGetSnapshotError,
4− OrchestrationThreadStreamItem,
5−} from "@t3tools/contracts";
6−import * as Deferred from "effect/Deferred";
7−import * as Duration from "effect/Duration";
8−import * as Effect from "effect/Effect";
9−import * as Fiber from "effect/Fiber";
10−import * as Predicate from "effect/Predicate";
11−import * as Queue from "effect/Queue";
12−import * as Semaphore from "effect/Semaphore";
13−import * as Stream from "effect/Stream";
14−
15−import { projectActivityEvent } from "./ActivityPayloadProjection.ts";
16−import { makeLiveStreamBudget, type RetainedLiveItem } from "./LiveStreamBudget.ts";
17−
18−const COALESCE_WINDOW = Duration.millis(50);
19−const MAX_PENDING_UPDATES = 512;
20−
21−export type ThreadLiveInput =
22− | { readonly kind: "event"; readonly event: OrchestrationEvent }
23− | { readonly kind: "synchronized" };
24−
25−function isToolUpdated(event: OrchestrationEvent): boolean {
26− return (
27− event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated"
28− );
29−}
30−
31−function asTrimmedString(value: unknown): string | null {
32− if (!Predicate.isString(value)) {
33− return null;
34− }
35− const trimmed = value.trim();
36− return trimmed.length > 0 ? trimmed : null;
37−}
38−
39−function stableToolCallIdentity(event: OrchestrationEvent): string | null {
40− if (event.type !== "thread.activity-appended") {
41− return null;
42− }
43− const payload = event.payload.activity.payload;
44− if (!Predicate.isObject(payload)) {
45− return null;
46− }
47− const data = Predicate.isObject(payload.data) ? payload.data : null;
48− return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId);
49−}
50−
51−/**
52− * Retain only the latest in-flight update for each stable tool-call id in a
53− * live run. Anonymous calls pass through because labels are not unique when
54− * tools execute in parallel. Survivors remain in sequence order.
55− */
56−export function coalesceLiveToolUpdatedEvents(
57− events: ReadonlyArray<OrchestrationEvent>,
58−): ReadonlyArray<OrchestrationEvent> {
59− const survivors: Array<OrchestrationEvent> = [];
60− let pendingUpdates: Array<OrchestrationEvent> = [];
61−
62− const flushUpdates = () => {
63− const seen = new Set<string>();
64− const latestUpdates: Array<OrchestrationEvent> = [];
65− for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) {
66− const event = pendingUpdates[index]!;
67− const identity = stableToolCallIdentity(event);
68− const activity =
69− event.type === "thread.activity-appended" ? event.payload.activity : undefined;
70− const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null;
71− if (key && seen.has(key)) {
72− continue;
73− }
74− if (key) {
75− seen.add(key);
76− }
77− latestUpdates.push(event);
78− }
79− latestUpdates.reverse();
80− survivors.push(...latestUpdates);
81− pendingUpdates = [];
82− };
83−
84− for (const event of events) {
85− if (isToolUpdated(event)) {
86− pendingUpdates.push(event);
87− continue;
88− }
89− flushUpdates();
90− survivors.push(event);
91− }
92− flushUpdates();
93− return survivors;
94−}
95−
96−export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")(
97− function* (options?: {
98− readonly coalesceWindow?: Duration.Input;
99− readonly maxItems?: number;
100− readonly maxSerializedBytes?: number;
101− }) {
102− const coalescerScope = yield* Effect.scope;
103− const budget = yield* makeLiveStreamBudget(options);
104− const cleanupComplete = yield* Deferred.make<void>();
105− const output = yield* Queue.unbounded<
106− RetainedLiveItem<OrchestrationThreadStreamItem>,
107− OrchestrationGetSnapshotError
108− >();
109− const mutex = yield* Semaphore.make(1);
110− const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW;
111− let pendingUpdates: Array<RetainedLiveItem<OrchestrationEvent>> = [];
112− let windowGeneration = 0;
113− let windowFiber: Fiber.Fiber<void, never> | null = null;
114− let closed = false;
115−
116− const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () {
117− const fiber = windowFiber;
118− if (!fiber) {
119− return;
120− }
121− windowFiber = null;
122− yield* Fiber.interrupt(fiber);
123− });
124−
125− const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* () {
126− if (pendingUpdates.length === 0) {
127− return;
128− }
129− const items = yield* budget.replace(
130− pendingUpdates,
131− coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({
132− kind: "event" as const,
133− event,
134− })),
135− (item) => item.event,
136− );
137− pendingUpdates = [];
138− yield* Queue.offerAll(output, items);
139− }, Effect.uninterruptible);
140−
141− const flushWindow = (generation: number) =>
142− Effect.sleep(coalesceWindow).pipe(
143− Effect.andThen(
144− mutex.withPermits(1)(
145− Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)),
146− ),
147− ),
148− Effect.ensuring(
149− Effect.sync(() => {
150− if (generation === windowGeneration) {
151− windowFiber = null;
152− }
153− }),
154− ),
155− Effect.catchTags({ OrchestrationGetSnapshotError: () => Effect.void }),
156− );
157−
158− // Keep each source batch together so a synchronization marker cannot pass
159− // events already pulled from PubSub but still being coalesced.
160− const offerAll = Effect.fn("ThreadLiveEventCoalescer.offerAll")(function* (
161− inputs: ReadonlyArray<ThreadLiveInput>,
162− ) {
163− yield* mutex.withPermits(1)(
164− Effect.forEach(
165− inputs,
166− (input) =>
167− Effect.gen(function* () {
168− yield* budget.check;
169− if (input.kind === "event") {
170− // Retain only the client payload, not full persisted tool output.
171− yield* budget.retain(projectActivityEvent(input.event)).pipe(
172− Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))),
173− Effect.uninterruptible,
174− );
175− }
176− if (input.kind === "event" && isToolUpdated(input.event)) {
177− if (pendingUpdates.length === 1) {
178− const generation = ++windowGeneration;
179− windowFiber = yield* Effect.forkIn(flushWindow(generation), coalescerScope);
180− }
181− if (pendingUpdates.length >= MAX_PENDING_UPDATES) {
182− yield* cancelWindow();
183− windowGeneration += 1;
184− yield* flushPending();
185− }
186− return;
187− }
188−
189− yield* cancelWindow();
190− windowGeneration += 1;
191− // A non-update event closes the run immediately. The coalescer keeps
192− // that boundary after the final update from the run.
193− yield* flushPending();
194− if (input.kind === "synchronized") {
195− yield* budget.retain({ kind: "synchronized" as const }).pipe(
196− Effect.flatMap((marker) => Queue.offer(output, marker)),
197− Effect.uninterruptible,
198− );
199− }
200− }),
201− { discard: true },
202− ),
203− );
204− });
205−
206− const close = (error?: OrchestrationGetSnapshotError) =>
207− mutex.withPermits(1)(
208− Effect.gen(function* () {
209− if (closed) {
210− return;
211− }
212− closed = true;
213− windowGeneration += 1;
214− yield* cancelWindow();
215− budget.release(pendingUpdates);
216− pendingUpdates = [];
217− budget.release(yield* Queue.clear(output).pipe(Effect.orDie));
218− if (error) {
219− yield* Queue.fail(output, error);
220− }
221− yield* Queue.shutdown(output);
222− yield* Deferred.succeed(cleanupComplete, undefined);
223− }),
224− );
225−
226− yield* Effect.addFinalizer(() => close());
227− yield* budget.failed.pipe(
228− Effect.catchTags({ OrchestrationGetSnapshotError: close }),
229− Effect.forkScoped,
230− );
231−
232− return {
233− offer: (input: ThreadLiveInput) => offerAll([input]),
234− offerAll,
235− stream: budget.deliver(Stream.fromQueue(output)),
236− failed: budget.failed,
237− closed: Deferred.await(cleanupComplete),
238− usage: budget.usage,
239− } as const;
240− },
241−);
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+0−858
@@ -1,858 +0,0 @@
1−import {
2− DEFAULT_SERVER_SETTINGS,
3− ProjectId,
4− ProviderInstanceId,
5− PullRequestOperationError,
6− ThreadId,
7− type OrchestrationCommand,
8− type OrchestrationProjectShell,
9− type OrchestrationShellSnapshot,
10− type OrchestrationThreadShell,
11− type PullRequestSummary,
12− type ServerSettings,
13− type ServerSettingsPatch,
14−} from "@t3tools/contracts";
15−import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
16−import { assert, describe, it } from "@effect/vitest";
17−import * as Crypto from "effect/Crypto";
18−import * as Deferred from "effect/Deferred";
19−import * as Effect from "effect/Effect";
20−import * as FileSystem from "effect/FileSystem";
21−import * as Layer from "effect/Layer";
22−import * as PubSub from "effect/PubSub";
23−import * as Queue from "effect/Queue";
24−import * as Ref from "effect/Ref";
25−import * as Stream from "effect/Stream";
26−import { TestClock } from "effect/testing";
27−
28−import { GitManager } from "../git/GitManager.ts";
29−import {
30− PullRequestService,
31− type PullRequestMergeEvent,
32−} from "../pullRequest/PullRequestService.ts";
33−import { ServerActivation } from "../serverActivation.ts";
34−import { ServerSettingsService } from "../serverSettings.ts";
35−import { OrchestrationCommandInvariantError } from "./Errors.ts";
36−import {
37− OrchestrationEngineService,
38− type OrchestrationEngineShape,
39−} from "./Services/OrchestrationEngine.ts";
40−import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
41−import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
42−
43−const NOW = "2026-08-28T12:00:00.000Z";
44−const PROJECT_ID = ProjectId.make("settlement-project");
45−const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
46−
47−type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>;
48−
49−const testCrypto = Crypto.make({
50− randomBytes: (size) => new Uint8Array(size).fill(1),
51− digest: (_algorithm, data) => Effect.succeed(data),
52−});
53−
54−function makeProject(
55− id: ProjectId = PROJECT_ID,
56− workspaceRoot = "/workspace/project",
57−): OrchestrationProjectShell {
58− return {
59− id,
60− title: `Project ${id}`,
61− workspaceRoot,
62− defaultModelSelection: null,
63− scripts: [],
64− createdAt: "2026-08-01T00:00:00.000Z",
65− updatedAt: NOW,
66− };
67−}
68−
69−function makeThread(
70− id: string,
71− overrides: Partial<OrchestrationThreadShell> = {},
72−): OrchestrationThreadShell {
73− return {
74− id: ThreadId.make(id),
75− projectId: PROJECT_ID,
76− title: id,
77− modelSelection: {
78− instanceId: ProviderInstanceId.make("codex"),
79− model: "gpt-5",
80− },
81− runtimeMode: "full-access",
82− interactionMode: "default",
83− branch: null,
84− worktreePath: null,
85− latestTurn: null,
86− createdAt: "2026-08-01T00:00:00.000Z",
87− updatedAt: "2026-08-20T00:00:00.000Z",
88− archivedAt: null,
89− settledOverride: null,
90− settledAt: null,
91− session: null,
92− latestUserMessageAt: "2026-08-20T00:00:00.000Z",
93− hasPendingApprovals: false,
94− hasPendingUserInput: false,
95− hasActionableProposedPlan: false,
96− ...overrides,
97− };
98−}
99−
100−function makeSnapshot(
101− threads: ReadonlyArray<OrchestrationThreadShell>,
102− projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
103−): OrchestrationShellSnapshot {
104− return {
105− snapshotSequence: 1,
106− projects,
107− threads,
108− updatedAt: NOW,
109− };
110−}
111−
112−function makePullRequestSummary(input: {
113− readonly projectId: ProjectId;
114− readonly repository: string;
115− readonly number: number;
116− readonly state: "open" | "closed" | "merged";
117− readonly updatedAt?: string;
118−}): PullRequestSummary {
119− return {
120− provider: "github",
121− projectId: input.projectId,
122− repository: input.repository,
123− number: input.number,
124− title: "Pull request",
125− url: `https://example.test/${input.repository}/pull/${input.number}`,
126− state: input.state,
127− headBranch: "feature",
128− baseBranch: "main",
129− updatedAt: input.updatedAt ?? NOW,
130− };
131−}
132−
133−interface HarnessOptions {
134− readonly snapshot: OrchestrationShellSnapshot;
135− readonly settings?: ServerSettings;
136− readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
137− readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
138− readonly existingWorktreePaths?: ReadonlyArray<string>;
139− readonly onDispatch?: (
140− command: AutoSettleCommand,
141− ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
142−}
143−
144−const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) {
145− const activation = yield* Deferred.make<void>();
146− const snapshots = yield* Ref.make(options.snapshot);
147− const snapshotReadCount = yield* Ref.make(0);
148− const snapshotReads = yield* Queue.unbounded<number>();
149− const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
150− const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
151− const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>();
152− const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
153− const branchCalls = yield* Ref.make<
154− ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
155− >([]);
156− const summaryCalls = yield* Ref.make<
157− ReadonlyArray<{
158− readonly projectId: ProjectId;
159− readonly repository: string;
160− readonly number: number;
161− }>
162− >([]);
163− const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
164− const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
165−
166− const updateSettings = (patch: ServerSettingsPatch) =>
167− Effect.gen(function* () {
168− const next = applyServerSettingsPatch(yield* Ref.get(settings), patch);
169− yield* Ref.set(settings, next);
170− yield* PubSub.publish(settingsChanges, next);
171− return next;
172− });
173−
174− const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) =>
175− Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
176− Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
177− );
178− const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
179− Effect.gen(function* () {
180− yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
181− yield* Ref.update(summaryRecovery, (values) => [
182− ...values,
183− readOptions?.recoverTransientFailure,
184− ]);
185− return yield* (
186− options.pullRequestSummary?.(input, readOptions) ??
187− Effect.succeed(
188− makePullRequestSummary({
189− ...input,
190− state: "open",
191− }),
192− )
193− );
194− });
195−
196− const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
197− if (command.type !== "thread.auto-settle") {
198− return Effect.die(new Error(`Unexpected command: ${command.type}`));
199− }
200− return Ref.update(commands, (recorded) => [...recorded, command]).pipe(
201− Effect.andThen(options.onDispatch?.(command) ?? Effect.void),
202− Effect.as({ sequence: 1 }),
203− );
204− };
205−
206− const serverSettings = ServerSettingsService.of({
207− start: Effect.void,
208− ready: Effect.void,
209− getSettings: Ref.get(settings),
210− updateSettings,
211− streamChanges: Stream.fromPubSub(settingsChanges),
212− subscribeChanges: PubSub.subscribe(settingsChanges).pipe(
213− Effect.map((subscription) => Stream.fromSubscription(subscription)),
214− ),
215− });
216−
217− const dependencies = Layer.mergeAll(
218− Layer.mock(ProjectionSnapshotQuery)({
219− getShellSnapshot: () =>
220− Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
221− Effect.tap((count) => Queue.offer(snapshotReads, count)),
222− Effect.andThen(Ref.get(snapshots)),
223− ),
224− }),
225− Layer.mock(GitManager)({
226− branchPullRequest,
227− invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]),
228− }),
229− Layer.mock(PullRequestService)({
230− summary: pullRequestSummary,
231− subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
232− Effect.map((subscription) => Stream.fromSubscription(subscription)),
233− ),
234− }),
235− Layer.mock(OrchestrationEngineService)({
236− readEvents: () => Stream.empty,
237− dispatch,
238− streamDomainEvents: Stream.empty,
239− latestSequence: Effect.succeed(0),
240− }),
241− Layer.succeed(ServerSettingsService, serverSettings),
242− Layer.succeed(ServerActivation, Deferred.await(activation)),
243− Layer.succeed(Crypto.Crypto, testCrypto),
244− FileSystem.layerNoop({
245− exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false),
246− }),
247− );
248−
249− return {
250− activation,
251− snapshots,
252− snapshotReadCount,
253− snapshotReads,
254− commands,
255− branchCalls,
256− summaryCalls,
257− summaryRecovery,
258− invalidatedCwds,
259− updateSettings,
260− publishMerge: PubSub.publish(mergedPullRequests, {
261− projectId: PROJECT_ID,
262− repository: "owner/repository",
263− number: 42,
264− mergedAt: NOW,
265− }),
266− layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
267− };
268−});
269−
270−const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
271− reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"],
272− activation: Deferred.Deferred<void>,
273− snapshotReads: Queue.Queue<number>,
274−) {
275− yield* reactor.start();
276− yield* Deferred.succeed(activation, undefined);
277− yield* Queue.take(snapshotReads);
278− yield* reactor.drain;
279−});
280−
281−describe("ThreadSettlementReactor", () => {
282− it.effect("starts without clients and skips protected threads before pull request lookup", () =>
283− Effect.scoped(
284− Effect.gen(function* () {
285− yield* TestClock.setTime(Date.parse(NOW));
286− const linkedPullRequest = {
287− projectId: LINKED_PROJECT_ID,
288− repository: "owner/repository",
289− number: 42,
290− url: "https://example.test/owner/repository/pull/42",
291− } as const;
292− const skipped = [
293− makeThread("pending-approval", {
294− branch: "skip-approval",
295− hasPendingApprovals: true,
296− }),
297− makeThread("snoozed", {
298− branch: "skip-snoozed",
299− snoozedUntil: "2026-08-29T00:00:00.000Z",
300− }),
301− ];
302− const fixture = yield* makeHarness({
303− snapshot: makeSnapshot(
304− [
305− makeThread("inactive", { branch: "inactive-feature" }),
306− makeThread("closed-pr", { linkedPullRequest }),
307− ...skipped,
308− ],
309− [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
310− ),
311− branchPullRequest: () => Effect.succeed(null),
312− pullRequestSummary: (input) =>
313− Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })),
314− });
315−
316− yield* Effect.gen(function* () {
317− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
318− yield* reactor.start();
319− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);
320−
321− yield* Deferred.succeed(fixture.activation, undefined);
322− yield* Queue.take(fixture.snapshotReads);
323− yield* reactor.drain;
324−
325− const commands = yield* Ref.get(fixture.commands);
326− assert.deepStrictEqual(
327− commands
328− .map(({ threadId, snapshotSequence, settledAt }) => ({
329− threadId,
330− snapshotSequence,
331− settledAt,
332− }))
333− .sort((left, right) => left.threadId.localeCompare(right.threadId)),
334− [
335− {
336− threadId: ThreadId.make("closed-pr"),
337− snapshotSequence: 1,
338− settledAt: "2026-08-20T00:00:00.000Z",
339− },
340− {
341− threadId: ThreadId.make("inactive"),
342− snapshotSequence: 1,
343− settledAt: "2026-08-20T00:00:00.000Z",
344− },
345− ],
346− );
347− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
348− { cwd: "/workspace/project", branch: "inactive-feature" },
349− ]);
350− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
351− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
352− ]);
353− assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]);
354− }).pipe(Effect.provide(fixture.layer));
355− }),
356− ),
357− );
358−
359− it.effect("reevaluates inactivity and pull request state once per minute", () =>
360− Effect.scoped(
361− Effect.gen(function* () {
362− yield* TestClock.setTime(Date.parse(NOW));
363− const pullRequest = yield* Ref.make<"open" | "merged">("open");
364− const fixture = yield* makeHarness({
365− snapshot: makeSnapshot([
366− makeThread("at-boundary", {
367− latestUserMessageAt: "2026-08-25T12:00:00.000Z",
368− }),
369− makeThread("open-pr", {
370− branch: "saved-feature",
371− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
372− }),
373− ]),
374− branchPullRequest: () =>
375− Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))),
376− });
377−
378− yield* Effect.gen(function* () {
379− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
380− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
381− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
382−
383− yield* Ref.set(pullRequest, "merged");
384− yield* TestClock.adjust("1 minute");
385− yield* Queue.take(fixture.snapshotReads);
386− yield* reactor.drain;
387−
388− assert.deepStrictEqual(
389− (yield* Ref.get(fixture.commands))
390− .map((command) => command.threadId)
391− .sort((left, right) => left.localeCompare(right)),
392− [ThreadId.make("at-boundary"), ThreadId.make("open-pr")],
393− );
394− assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2);
395− }).pipe(Effect.provide(fixture.layer));
396− }),
397− ),
398− );
399−
400− it.effect("reevaluates immediately after a pull request merge", () =>
401− Effect.scoped(
402− Effect.gen(function* () {
403− yield* TestClock.setTime(Date.parse(NOW));
404− const periodicLookupStarted = yield* Deferred.make<void>();
405− const releasePeriodicLookup = yield* Deferred.make<void>();
406− const mergedThreadSettled = yield* Deferred.make<void>();
407− const branchLookupCount = yield* Ref.make(0);
408− const fixture = yield* makeHarness({
409− snapshot: makeSnapshot([
410− makeThread("merged-in-app", {
411− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
412− linkedPullRequest: {
413− projectId: PROJECT_ID,
414− repository: "owner/repository",
415− number: 42,
416− url: "https://example.test/owner/repository/pull/42",
417− },
418− }),
419− makeThread("slow-periodic-lookup", {
420− branch: "another-feature",
421− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
422− }),
423− ]),
424− branchPullRequest: () =>
425− Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(
426− Effect.flatMap((count) =>
427− count === 1
428− ? Effect.succeed({ state: "open" as const, updatedAt: NOW })
429− : Deferred.succeed(periodicLookupStarted, undefined).pipe(
430− Effect.andThen(Deferred.await(releasePeriodicLookup)),
431− Effect.as({ state: "open" as const, updatedAt: NOW }),
432− ),
433− ),
434− ),
435− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
436− });
437−
438− yield* Effect.gen(function* () {
439− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
440− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
441− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
442− yield* Deferred.await(periodicLookupStarted);
443−
444− yield* fixture.publishMerge;
445− yield* Deferred.await(mergedThreadSettled);
446−
447− assert.deepStrictEqual(
448− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
449− [ThreadId.make("merged-in-app")],
450− );
451− yield* Deferred.succeed(releasePeriodicLookup, undefined);
452− yield* reactor.drain;
453− }).pipe(Effect.provide(fixture.layer));
454− }),
455− ),
456− );
457−
458− it.effect(
459− "settles branch threads on a pull request merge without waiting for the next sweep",
460− () =>
461− Effect.scoped(
462− Effect.gen(function* () {
463− yield* TestClock.setTime(Date.parse(NOW));
464− const state = yield* Ref.make<"open" | "merged">("open");
465− const mergedThreadSettled = yield* Deferred.make<void>();
466− const fixture = yield* makeHarness({
467− snapshot: makeSnapshot([
468− makeThread("branch-thread", {
469− branch: "saved-feature",
470− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
471− }),
472− ]),
473− branchPullRequest: () =>
474− Ref.get(state).pipe(
475− Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
476− ),
477− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
478− });
479−
480− yield* Effect.gen(function* () {
481− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
482− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
483− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
484− assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []);
485−
486− yield* Ref.set(state, "merged");
487− yield* fixture.publishMerge;
488− yield* Deferred.await(mergedThreadSettled);
489−
490− assert.deepStrictEqual(
491− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
492− [ThreadId.make("branch-thread")],
493− );
494− assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]);
495− yield* reactor.drain;
496− }).pipe(Effect.provide(fixture.layer));
497− }),
498− ),
499− );
500−
501− it.effect("a merge does not settle threads linked to an unrelated pull request", () =>
502− Effect.scoped(
503− Effect.gen(function* () {
504− yield* TestClock.setTime(Date.parse(NOW));
505− const mergedThreadSettled = yield* Deferred.make<void>();
506− const mergeLookupStarted = yield* Deferred.make<void>();
507− const releaseMergeLookup = yield* Deferred.make<void>();
508− const lookupCount = yield* Ref.make(0);
509− const fixture = yield* makeHarness({
510− snapshot: makeSnapshot([
511− makeThread("merged-in-app", {
512− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
513− linkedPullRequest: {
514− projectId: PROJECT_ID,
515− repository: "owner/repository",
516− number: 42,
517− url: "https://example.test/owner/repository/pull/42",
518− },
519− }),
520− makeThread("unrelated-linked", {
521− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
522− linkedPullRequest: {
523− projectId: PROJECT_ID,
524− repository: "owner/repository",
525− number: 99,
526− url: "https://example.test/owner/repository/pull/99",
527− },
528− }),
529− ]),
530− pullRequestSummary: (input) =>
531− Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
532− // The initial sweep looks up both linked threads; the merge
533− // sweep only looks up the unrelated one, since the merged
534− // thread settles from the event itself.
535− Effect.tap((count) =>
536− count === 3 ? Deferred.succeed(mergeLookupStarted, undefined) : Effect.void,
537− ),
538− Effect.tap((count) =>
539− count === 3 ? Deferred.await(releaseMergeLookup) : Effect.void,
540− ),
541− Effect.map(() => makePullRequestSummary({ ...input, state: "open" })),
542− ),
543− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
544− });
545−
546− yield* Effect.gen(function* () {
547− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
548− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
549− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
550−
551− yield* fixture.publishMerge;
552− yield* Deferred.await(mergeLookupStarted);
553− yield* Deferred.await(mergedThreadSettled);
554− yield* Deferred.succeed(releaseMergeLookup, undefined);
555− yield* reactor.drain;
556−
557− assert.deepStrictEqual(
558− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
559− [ThreadId.make("merged-in-app")],
560− );
561− assert.deepStrictEqual(
562− (yield* Ref.get(fixture.summaryCalls))
563− .map((call) => call.number)
564− .toSorted((left, right) => left - right),
565− [42, 99, 99],
566− );
567− }).pipe(Effect.provide(fixture.layer));
568− }),
569− ),
570− );
571−
572− it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>
573− Effect.scoped(
574− Effect.gen(function* () {
575− yield* TestClock.setTime(Date.parse(NOW));
576− const state = yield* Ref.make<"merged" | "closed">("merged");
577− const firstLookupStarted = yield* Deferred.make<void>();
578− const releaseFirstLookup = yield* Deferred.make<void>();
579− const laterLookupStarted = yield* Deferred.make<void>();
580− const releaseLaterLookup = yield* Deferred.make<void>();
581− const lookupCount = yield* Ref.make(0);
582− const fixture = yield* makeHarness({
583− snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]),
584− settings: {
585− ...DEFAULT_SERVER_SETTINGS,
586− sidebarAutoSettleAfterDays: null,
587− sidebarAutoSettleOnMerge: true,
588− },
589− branchPullRequest: () =>
590− Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
591− Effect.tap((count) =>
592− count === 1
593− ? Deferred.succeed(firstLookupStarted, undefined)
594− : count === 3
595− ? Deferred.succeed(laterLookupStarted, undefined)
596− : Effect.void,
597− ),
598− Effect.tap((count) =>
599− count === 1
600− ? Deferred.await(releaseFirstLookup)
601− : count === 3
602− ? Deferred.await(releaseLaterLookup)
603− : Effect.void,
604− ),
605− Effect.andThen(Ref.get(state)),
606− Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
607− ),
608− });
609−
610− yield* Effect.gen(function* () {
611− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
612− yield* reactor.start();
613− yield* Deferred.succeed(fixture.activation, undefined);
614− yield* Queue.take(fixture.snapshotReads);
615− yield* Deferred.await(firstLookupStarted);
616−
617− yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false });
618− yield* Deferred.succeed(releaseFirstLookup, undefined);
619− yield* Queue.take(fixture.snapshotReads);
620− yield* reactor.drain;
621− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
622− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2);
623−
624− yield* Ref.set(state, "closed");
625− yield* fixture.updateSettings({ enableAgentBrowserAccess: false });
626− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 });
627− yield* Deferred.await(laterLookupStarted);
628− yield* Deferred.succeed(releaseLaterLookup, undefined);
629− yield* reactor.drain;
630−
631− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3);
632− assert.strictEqual(yield* Ref.get(lookupCount), 3);
633− assert.deepStrictEqual(
634− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
635− [ThreadId.make("settings-thread")],
636− );
637− }).pipe(Effect.provide(fixture.layer));
638− }),
639− ),
640− );
641−
642− it.effect("keeps an unknown pull request active and continues with other candidates", () =>
643− Effect.scoped(
644− Effect.gen(function* () {
645− yield* TestClock.setTime(Date.parse(NOW));
646− const fixture = yield* makeHarness({
647− snapshot: makeSnapshot(
648− [
649− makeThread("lookup-failed", {
650− linkedPullRequest: {
651− projectId: LINKED_PROJECT_ID,
652− repository: "owner/repository",
653− number: 9,
654− url: "https://example.test/owner/repository/pull/9",
655− },
656− }),
657− makeThread("inactive-without-pr"),
658− ],
659− [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
660− ),
661− pullRequestSummary: () =>
662− Effect.fail(
663− new PullRequestOperationError({
664− operation: "summary",
665− detail: "host unavailable",
666− }),
667− ),
668− });
669−
670− yield* Effect.gen(function* () {
671− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
672− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
673−
674− assert.deepStrictEqual(
675− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
676− [ThreadId.make("inactive-without-pr")],
677− );
678− assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
679− }).pipe(Effect.provide(fixture.layer));
680− }),
681− ),
682− );
683−
684− it.effect("keeps threads active when their pull request project is unavailable", () =>
685− Effect.scoped(
686− Effect.gen(function* () {
687− yield* TestClock.setTime(Date.parse(NOW));
688− const linkedPullRequest = {
689− projectId: LINKED_PROJECT_ID,
690− repository: "owner/repository",
691− number: 10,
692− url: "https://example.test/owner/repository/pull/10",
693− } as const;
694− const fixture = yield* makeHarness({
695− snapshot: makeSnapshot(
696− [
697− makeThread("missing-own-project", {
698− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
699− linkedPullRequest,
700− }),
701− makeThread("missing-branch-project", { branch: "saved-feature" }),
702− ],
703− [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
704− ),
705− pullRequestSummary: (input) =>
706− Effect.succeed(makePullRequestSummary({ ...input, state: "open" })),
707− });
708−
709− yield* Effect.gen(function* () {
710− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
711− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
712−
713− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
714− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
715− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
716− ]);
717− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
718− }).pipe(Effect.provide(fixture.layer));
719− }),
720− ),
721− );
722−
723− it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () =>
724− Effect.scoped(
725− Effect.gen(function* () {
726− yield* TestClock.setTime(Date.parse(NOW));
727− const linkedPullRequest = {
728− projectId: LINKED_PROJECT_ID,
729− repository: "owner/repository",
730− number: 77,
731− url: "https://example.test/owner/repository/pull/77",
732− } as const;
733− const fixture = yield* makeHarness({
734− snapshot: makeSnapshot(
735− [
736− makeThread("branch-one", {
737− branch: "saved-feature",
738− worktreePath: "/deleted/worktree-one",
739− }),
740− makeThread("branch-two", {
741− branch: "saved-feature",
742− worktreePath: "/deleted/worktree-two",
743− }),
744− makeThread("linked-one", { linkedPullRequest }),
745− makeThread("linked-two", { linkedPullRequest }),
746− ],
747− [
748− makeProject(PROJECT_ID, "/workspace/project-root"),
749− makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
750− ],
751− ),
752− branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
753− pullRequestSummary: (input) =>
754− Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
755− });
756−
757− yield* Effect.gen(function* () {
758− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
759− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
760−
761− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
762− { cwd: "/workspace/project-root", branch: "saved-feature" },
763− ]);
764− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
765− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
766− ]);
767− assert.deepStrictEqual(
768− new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
769− new Set([
770− ThreadId.make("branch-one"),
771− ThreadId.make("branch-two"),
772− ThreadId.make("linked-one"),
773− ThreadId.make("linked-two"),
774− ]),
775− );
776− }).pipe(Effect.provide(fixture.layer));
777− }),
778− ),
779− );
780−
781− it.effect("looks up the branch pull request from a thread's live worktree", () =>
782− Effect.scoped(
783− Effect.gen(function* () {
784− yield* TestClock.setTime(Date.parse(NOW));
785− const fixture = yield* makeHarness({
786− snapshot: makeSnapshot(
787− [
788− makeThread("live-worktree", {
789− branch: "feature/live",
790− worktreePath: "/workspace/project-root/.worktrees/live",
791− }),
792− makeThread("deleted-worktree", {
793− branch: "feature/deleted",
794− worktreePath: "/workspace/project-root/.worktrees/deleted",
795− }),
796− ],
797− [makeProject(PROJECT_ID, "/workspace/project-root")],
798− ),
799− existingWorktreePaths: ["/workspace/project-root/.worktrees/live"],
800− });
801−
802− yield* Effect.gen(function* () {
803− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
804− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
805−
806− assert.deepStrictEqual(
807− new Set(yield* Ref.get(fixture.branchCalls)),
808− new Set([
809− { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" },
810− { cwd: "/workspace/project-root", branch: "feature/deleted" },
811− ]),
812− );
813− }).pipe(Effect.provide(fixture.layer));
814− }),
815− ),
816− );
817−
818− it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
819− Effect.scoped(
820− Effect.gen(function* () {
821− yield* TestClock.setTime(Date.parse(NOW));
822− const fixture = yield* makeHarness({
823− snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]),
824− onDispatch: (command) =>
825− command.threadId === ThreadId.make("stale")
826− ? Effect.fail(
827− new OrchestrationCommandInvariantError({
828− commandType: command.type,
829− detail: "thread changed after settlement evaluation",
830− }),
831− )
832− : Effect.void,
833− });
834−
835− yield* Effect.gen(function* () {
836− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
837− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
838−
839− const firstSweep = yield* Ref.get(fixture.commands);
840− assert.strictEqual(
841− firstSweep.find((command) => command.threadId === ThreadId.make("stale"))
842− ?.snapshotSequence,
843− 1,
844− );
845− assert.strictEqual(
846− firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")),
847− true,
848− );
849−
850− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
851− yield* Queue.take(fixture.snapshotReads);
852− yield* reactor.drain;
853− assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4);
854− }).pipe(Effect.provide(fixture.layer));
855− }),
856− ),
857− );
858−});
apps/server/src/orchestration/ThreadSettlementReactor.ts+0−249
@@ -1,249 +0,0 @@
1−import { CommandId } from "@t3tools/contracts";
2−import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3−import * as Cause from "effect/Cause";
4−import * as Context from "effect/Context";
5−import * as Crypto from "effect/Crypto";
6−import * as DateTime from "effect/DateTime";
7−import * as Effect from "effect/Effect";
8−import * as FileSystem from "effect/FileSystem";
9−import * as Layer from "effect/Layer";
10−import * as Schedule from "effect/Schedule";
11−import type * as Scope from "effect/Scope";
12−import * as Stream from "effect/Stream";
13−
14−import * as GitManager from "../git/GitManager.ts";
15−import * as PullRequestService from "../pullRequest/PullRequestService.ts";
16−import * as ServerSettings from "../serverSettings.ts";
17−import { forkParked } from "../serverActivation.ts";
18−import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
19−import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
20−import {
21− isAutoSettlementCandidate,
22− resolveAutoSettlementAt,
23− type SettlementPullRequest,
24−} from "./ThreadSettlementPolicy.ts";
25−
26−export class ThreadSettlementReactor extends Context.Service<
27− ThreadSettlementReactor,
28− {
29− readonly start: () => Effect.Effect<void, never, Scope.Scope>;
30− readonly drain: Effect.Effect<void>;
31− }
32−>()("t3/orchestration/ThreadSettlementReactor") {}
33−
34−export const make = Effect.gen(function* () {
35− const engine = yield* OrchestrationEngine.OrchestrationEngineService;
36− const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
37− const settingsService = yield* ServerSettings.ServerSettingsService;
38− const git = yield* GitManager.GitManager;
39− const pullRequests = yield* PullRequestService.PullRequestService;
40− const crypto = yield* Crypto.Crypto;
41− const fileSystem = yield* FileSystem.FileSystem;
42−
43− const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (
44− mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
45− ) {
46− const snapshot = yield* snapshots.getShellSnapshot();
47− const now = DateTime.formatIso(yield* DateTime.now);
48− const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
49− // A merge event re-sweeps every candidate, not just the threads linked to
50− // the merged pull request: most threads carry no link and settle from
51− // their branch lookup, which would otherwise wait for the next minute's
52− // sweep on a possibly stale cached answer.
53− const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
54− // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
55− const lookupCwdByThreadId = new Map<string, string>();
56− yield* Effect.forEach(
57− candidates,
58− (thread) =>
59− Effect.gen(function* () {
60− const project = projects.get(thread.projectId);
61− if (project === undefined || thread.linkedPullRequest != null) return;
62− const worktreeExists =
63− thread.worktreePath !== null &&
64− (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
65− lookupCwdByThreadId.set(
66− thread.id,
67− worktreeExists && thread.worktreePath !== null
68− ? thread.worktreePath
69− : project.workspaceRoot,
70− );
71− }),
72− { concurrency: 8, discard: true },
73− );
74− if (mergedPullRequest !== null) {
75− // The merge just confirmed a terminal state the lookup caches can still
76− // call open (branch answers live two minutes, the sweep runs every
77− // minute). Drop the swept checkouts' cached answers so the merge settles
78− // its branch threads now instead of on a later sweep. Threads linked to
79− // the merged pull request settle from the event itself below and need no
80− // lookup, so they are absent from this map by construction.
81− const cwds = [...new Set(lookupCwdByThreadId.values())];
82− yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {
83− concurrency: 8,
84− discard: true,
85− });
86− }
87− const lookupKey = (thread: (typeof candidates)[number]) => {
88− if (thread.linkedPullRequest != null) {
89− return JSON.stringify([
90− "linked",
91− thread.linkedPullRequest.projectId,
92− thread.linkedPullRequest.repository,
93− thread.linkedPullRequest.number,
94− ]);
95− }
96− if (thread.branch === null) return JSON.stringify(["none", thread.id]);
97− const cwd = lookupCwdByThreadId.get(thread.id);
98− return JSON.stringify(
99− cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch],
100− );
101− };
102− const groups = Map.groupBy(candidates, lookupKey);
103−
104− const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
105− thread: (typeof candidates)[number],
106− ) {
107− if (thread.linkedPullRequest != null) {
108− // The event carries the merged state, so only the threads linked to
109− // that exact pull request settle from it. Every other linked thread
110− // falls through to a fresh summary lookup below: the merge sweep
111− // covers all candidates, and an unrelated merge must never settle
112− // them.
113− if (
114− mergedPullRequest !== null &&
115− thread.linkedPullRequest.projectId === mergedPullRequest.projectId &&
116− thread.linkedPullRequest.repository.toLowerCase() ===
117− mergedPullRequest.repository.toLowerCase() &&
118− thread.linkedPullRequest.number === mergedPullRequest.number
119− ) {
120− return {
121− state: "merged",
122− updatedAt: mergedPullRequest.mergedAt,
123− } satisfies SettlementPullRequest;
124− }
125− if (!projects.has(thread.linkedPullRequest.projectId)) {
126− return yield* Effect.die(new Error("linked pull request project not found"));
127− }
128− const summary = yield* pullRequests.summary(
129− {
130− projectId: thread.linkedPullRequest.projectId,
131− repository: thread.linkedPullRequest.repository,
132− number: thread.linkedPullRequest.number,
133− },
134− { recoverTransientFailure: false },
135− );
136− return {
137− state: summary.state,
138− updatedAt: summary.updatedAt,
139− } satisfies SettlementPullRequest;
140− }
141− if (thread.branch === null) return null;
142− const cwd = lookupCwdByThreadId.get(thread.id);
143− if (cwd === undefined) {
144− return yield* Effect.die(new Error("thread project not found"));
145− }
146− return yield* git.branchPullRequest({ cwd, branch: thread.branch });
147− });
148−
149− yield* Effect.forEach(
150− groups.values(),
151− (group) =>
152− Effect.gen(function* () {
153− const pullRequest = yield* pullRequestFor(group[0]!);
154− yield* Effect.forEach(
155− group,
156− (thread) =>
157− Effect.gen(function* () {
158− const settings = yield* settingsService.getSettings;
159− const decisionNow = DateTime.formatIso(yield* DateTime.now);
160− const settledAt = resolveAutoSettlementAt({
161− thread,
162− pullRequest,
163− now: decisionNow,
164− autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
165− autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
166− });
167− if (settledAt === null) {
168− return;
169− }
170− const uuid = yield* crypto.randomUUIDv4;
171− yield* engine.dispatch({
172− type: "thread.auto-settle",
173− commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
174− threadId: thread.id,
175− snapshotSequence: snapshot.snapshotSequence,
176− settledAt,
177− });
178− }).pipe(
179− Effect.catchCause((cause) =>
180− Cause.hasInterruptsOnly(cause)
181− ? Effect.failCause(cause)
182− : Effect.logWarning("automatic thread settlement skipped", {
183− threadId: thread.id,
184− cause: Cause.pretty(cause),
185− }),
186− ),
187− ),
188− { discard: true },
189− );
190− }).pipe(
191− Effect.catchCause((cause) =>
192− Cause.hasInterruptsOnly(cause)
193− ? Effect.failCause(cause)
194− : Effect.logWarning("automatic thread settlement skipped", {
195− threadIds: group.map((thread) => thread.id),
196− cause: Cause.pretty(cause),
197− }),
198− ),
199− ),
200− { concurrency: 8, discard: true },
201− );
202− });
203−
204− const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) =>
205− sweep(mergedPullRequest).pipe(
206− Effect.catchCause((cause) =>
207− Cause.hasInterruptsOnly(cause)
208− ? Effect.failCause(cause)
209− : Effect.logWarning("automatic thread settlement sweep failed", {
210− cause: Cause.pretty(cause),
211− }),
212− ),
213− );
214− const worker = yield* makeDrainableWorker(() => runSweep(null));
215−
216− const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
217− "ThreadSettlementReactor.start",
218− )(function* () {
219− const settingsChanges = yield* settingsService.subscribeChanges;
220− const mergedPullRequests = yield* pullRequests.subscribeMerges;
221− const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
222− let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
223− let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
224− yield* forkParked(
225− Effect.gen(function* () {
226− yield* worker.enqueue(undefined);
227− yield* worker.drain;
228− }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
229− );
230− yield* forkParked(
231− Stream.runForEach(settingsChanges, (settings) => {
232− if (
233− settings.sidebarAutoSettleAfterDays === lastAfterDays &&
234− settings.sidebarAutoSettleOnMerge === lastOnMerge
235− ) {
236− return Effect.void;
237− }
238− lastAfterDays = settings.sidebarAutoSettleAfterDays;
239− lastOnMerge = settings.sidebarAutoSettleOnMerge;
240− return worker.enqueue(undefined);
241− }),
242− );
243− yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep));
244− });
245−
246− return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];
247−});
248−
249−export const layer = Layer.effect(ThreadSettlementReactor, make);
apps/server/src/orchestration/decider.ts+1−0
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 type OrchestrationCommand,
44 type OrchestrationEvent,
55 type OrchestrationReadModel,
6+ type OrchestrationThread,
67 } from "@t3tools/contracts/legacy-orchestration";
78 import * as DateTime from "effect/DateTime";
89 import * as Crypto from "effect/Crypto";