| author | |
| committer | |
| log | a40aef4ccbc10b7bcd7bc817151db8d5a9b1c257 |
| tree | c652816f82524c36207701ce54e7f2c87363abb5 |
| parent | 48c176b3cfda7a714c10f18a12d398528ff4c2f9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>16 files changed, 690 insertions(+), 25 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+1-1| ... | ... | @@ -373,7 +373,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 373 | 373 | Layer.provideMerge( |
| 374 | 374 | Layer.succeed(ThreadDeletionReactor, { |
| 375 | 375 | start: () => Effect.void, |
| 376 | drain: Effect.void, | |
| 376 | drainThrough: () => Effect.void, | |
| 377 | 377 | }), |
| 378 | 378 | ), |
| 379 | 379 | Layer.provideMerge( |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+3| ... | ... | @@ -113,6 +113,7 @@ describe("OrchestrationEngine", () => { |
| 113 | 113 | detail: "historical replay should not be used during bootstrap", |
| 114 | 114 | }), |
| 115 | 115 | ), |
| 116 | hasEventAfter: () => Effect.succeed(false), | |
| 116 | 117 | }; |
| 117 | 118 | |
| 118 | 119 | const projectionSnapshot = { |
| ... | ... | @@ -812,6 +813,7 @@ describe("OrchestrationEngine", () => { |
| 812 | 813 | readAll() { |
| 813 | 814 | return Stream.fromIterable(events); |
| 814 | 815 | }, |
| 816 | hasEventAfter: () => Effect.succeed(false), | |
| 815 | 817 | }; |
| 816 | 818 | |
| 817 | 819 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| ... | ... | @@ -1048,6 +1050,7 @@ describe("OrchestrationEngine", () => { |
| 1048 | 1050 | readAll() { |
| 1049 | 1051 | return Stream.fromIterable(events); |
| 1050 | 1052 | }, |
| 1053 | hasEventAfter: () => Effect.succeed(false), | |
| 1051 | 1054 | }; |
| 1052 | 1055 | |
| 1053 | 1056 | let shouldFailProjection = true; |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+1-1| ... | ... | @@ -61,7 +61,7 @@ describe("OrchestrationReactor", () => { |
| 61 | 61 | started.push("thread-deletion-reactor"); |
| 62 | 62 | return Effect.void; |
| 63 | 63 | }, |
| 64 | drain: Effect.void, | |
| 64 | drainThrough: () => Effect.void, | |
| 65 | 65 | }), |
| 66 | 66 | ), |
| 67 | 67 | Layer.provideMerge( |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+251-1| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | TurnId, |
| 10 | 10 | ProviderInstanceId, |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | import * as Option from "effect/Option"; | |
| 12 | 13 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | 14 | import { assert, it } from "@effect/vitest"; |
| 14 | 15 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -31,6 +32,7 @@ import { |
| 31 | 32 | OrchestrationProjectionPipelineLive, |
| 32 | 33 | } from "./ProjectionPipeline.ts"; |
| 33 | 34 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 35 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | |
| 34 | 36 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 35 | 37 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; |
| 36 | 38 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| ... | ... | @@ -1277,6 +1279,111 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta |
| 1277 | 1279 | }, |
| 1278 | 1280 | ); |
| 1279 | 1281 | |
| 1282 | it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-replay-")))( | |
| 1283 | "OrchestrationProjectionPipeline", | |
| 1284 | (it) => { | |
| 1285 | it.effect("replaying a superseded thread.deleted keeps the re-created thread's files", () => | |
| 1286 | Effect.gen(function* () { | |
| 1287 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1288 | const path = yield* Path.Path; | |
| 1289 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 1290 | const eventStore = yield* OrchestrationEventStore; | |
| 1291 | const { attachmentsDir } = yield* ServerConfig; | |
| 1292 | const now = "2026-01-01T00:00:00.000Z"; | |
| 1293 | const projectId = ProjectId.make("project-replay"); | |
| 1294 | const retriedThreadId = ThreadId.make("thread-replay-retried"); | |
| 1295 | const goneThreadId = ThreadId.make("thread-replay-gone"); | |
| 1296 | const retriedAttachmentPath = path.join( | |
| 1297 | attachmentsDir, | |
| 1298 | "thread-replay-retried-00000000-0000-4000-8000-000000000001.png", | |
| 1299 | ); | |
| 1300 | const goneAttachmentPath = path.join( | |
| 1301 | attachmentsDir, | |
| 1302 | "thread-replay-gone-00000000-0000-4000-8000-000000000002.png", | |
| 1303 | ); | |
| 1304 | const threadCreated = (threadId: ThreadId, suffix: string) => | |
| 1305 | eventStore.append({ | |
| 1306 | type: "thread.created", | |
| 1307 | eventId: EventId.make(`evt-replay-create-${suffix}`), | |
| 1308 | aggregateKind: "thread", | |
| 1309 | aggregateId: threadId, | |
| 1310 | occurredAt: now, | |
| 1311 | commandId: CommandId.make(`cmd-replay-create-${suffix}`), | |
| 1312 | causationEventId: null, | |
| 1313 | correlationId: CorrelationId.make(`cmd-replay-create-${suffix}`), | |
| 1314 | metadata: {}, | |
| 1315 | payload: { | |
| 1316 | threadId, | |
| 1317 | projectId, | |
| 1318 | title: `Thread ${suffix}`, | |
| 1319 | modelSelection: { | |
| 1320 | instanceId: ProviderInstanceId.make("codex"), | |
| 1321 | model: "gpt-5-codex", | |
| 1322 | }, | |
| 1323 | runtimeMode: "full-access", | |
| 1324 | branch: null, | |
| 1325 | worktreePath: null, | |
| 1326 | createdAt: now, | |
| 1327 | updatedAt: now, | |
| 1328 | }, | |
| 1329 | }); | |
| 1330 | const threadDeleted = (threadId: ThreadId, suffix: string) => | |
| 1331 | eventStore.append({ | |
| 1332 | type: "thread.deleted", | |
| 1333 | eventId: EventId.make(`evt-replay-delete-${suffix}`), | |
| 1334 | aggregateKind: "thread", | |
| 1335 | aggregateId: threadId, | |
| 1336 | occurredAt: now, | |
| 1337 | commandId: CommandId.make(`cmd-replay-delete-${suffix}`), | |
| 1338 | causationEventId: null, | |
| 1339 | correlationId: CorrelationId.make(`cmd-replay-delete-${suffix}`), | |
| 1340 | metadata: {}, | |
| 1341 | payload: { threadId, deletedAt: now }, | |
| 1342 | }); | |
| 1343 | ||
| 1344 | yield* eventStore.append({ | |
| 1345 | type: "project.created", | |
| 1346 | eventId: EventId.make("evt-replay-project"), | |
| 1347 | aggregateKind: "project", | |
| 1348 | aggregateId: projectId, | |
| 1349 | occurredAt: now, | |
| 1350 | commandId: CommandId.make("cmd-replay-project"), | |
| 1351 | causationEventId: null, | |
| 1352 | correlationId: CorrelationId.make("cmd-replay-project"), | |
| 1353 | metadata: {}, | |
| 1354 | payload: { | |
| 1355 | projectId, | |
| 1356 | title: "Replay", | |
| 1357 | workspaceRoot: "/tmp/project-replay", | |
| 1358 | defaultModelSelection: null, | |
| 1359 | scripts: [], | |
| 1360 | createdAt: now, | |
| 1361 | updatedAt: now, | |
| 1362 | }, | |
| 1363 | }); | |
| 1364 | // A failed first send: create, roll back, then the draft retries the id. | |
| 1365 | yield* threadCreated(retriedThreadId, "retried-1"); | |
| 1366 | yield* threadDeleted(retriedThreadId, "retried"); | |
| 1367 | yield* threadCreated(retriedThreadId, "retried-2"); | |
| 1368 | // A thread that was deleted for good. | |
| 1369 | yield* threadCreated(goneThreadId, "gone"); | |
| 1370 | yield* threadDeleted(goneThreadId, "gone"); | |
| 1371 | ||
| 1372 | // Files on disk are not event-sourced: by the time anything replays, | |
| 1373 | // the retried thread's attachments already belong to its second life. | |
| 1374 | yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); | |
| 1375 | yield* fileSystem.writeFileString(retriedAttachmentPath, "second incarnation"); | |
| 1376 | yield* fileSystem.writeFileString(goneAttachmentPath, "gone"); | |
| 1377 | ||
| 1378 | yield* projectionPipeline.bootstrap; | |
| 1379 | ||
| 1380 | assert.isTrue(yield* exists(retriedAttachmentPath)); | |
| 1381 | assert.isFalse(yield* exists(goneAttachmentPath)); | |
| 1382 | }), | |
| 1383 | ); | |
| 1384 | }, | |
| 1385 | ); | |
| 1386 | ||
| 1280 | 1387 | it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1281 | 1388 | it.effect("replays a bootstrap backlog larger than the event store default limit", () => |
| 1282 | 1389 | Effect.gen(function* () { |
| ... | ... | @@ -2850,7 +2957,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta |
| 2850 | 2957 | |
| 2851 | 2958 | const engineLayer = it.layer( |
| 2852 | 2959 | OrchestrationEngineLive.pipe( |
| 2853 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | |
| 2960 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | |
| 2854 | 2961 | Layer.provide(ThreadBackgroundLiveness.layer), |
| 2855 | 2962 | Layer.provide(ThreadPlanProgress.layer), |
| 2856 | 2963 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | ... | @@ -2967,4 +3074,147 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 2967 | 3074 | ]); |
| 2968 | 3075 | }), |
| 2969 | 3076 | ); |
| 3077 | ||
| 3078 | it.effect("re-creating a deleted thread id starts from an empty projection", () => | |
| 3079 | Effect.gen(function* () { | |
| 3080 | const engine = yield* OrchestrationEngineService; | |
| 3081 | const snapshotQuery = yield* ProjectionSnapshotQuery; | |
| 3082 | const sql = yield* SqlClient.SqlClient; | |
| 3083 | const createdAt = "2026-01-01T00:00:00.000Z"; | |
| 3084 | const projectId = ProjectId.make("project-retry"); | |
| 3085 | const threadId = ThreadId.make("thread-retry"); | |
| 3086 | const modelSelection = { | |
| 3087 | instanceId: ProviderInstanceId.make("codex"), | |
| 3088 | model: "gpt-5-codex", | |
| 3089 | }; | |
| 3090 | const createThread = (commandId: string, title: string) => | |
| 3091 | engine.dispatch({ | |
| 3092 | type: "thread.create", | |
| 3093 | commandId: CommandId.make(commandId), | |
| 3094 | threadId, | |
| 3095 | projectId, | |
| 3096 | title, | |
| 3097 | modelSelection, | |
| 3098 | runtimeMode: "full-access", | |
| 3099 | interactionMode: "default", | |
| 3100 | branch: null, | |
| 3101 | worktreePath: null, | |
| 3102 | createdAt, | |
| 3103 | }); | |
| 3104 | const countRowsForThread = (table: string) => | |
| 3105 | sql<{ readonly count: number }>` | |
| 3106 | SELECT COUNT(*) AS count FROM ${sql(table)} WHERE thread_id = ${threadId} | |
| 3107 | `.pipe(Effect.map((rows) => rows[0]?.count ?? 0)); | |
| 3108 | const perThreadTables = [ | |
| 3109 | "projection_thread_messages", | |
| 3110 | "projection_thread_activities", | |
| 3111 | "projection_thread_sessions", | |
| 3112 | "projection_turns", | |
| 3113 | "projection_thread_proposed_plans", | |
| 3114 | "projection_pending_approvals", | |
| 3115 | ]; | |
| 3116 | ||
| 3117 | yield* engine.dispatch({ | |
| 3118 | type: "project.create", | |
| 3119 | commandId: CommandId.make("cmd-retry-project"), | |
| 3120 | projectId, | |
| 3121 | title: "Retry Project", | |
| 3122 | workspaceRoot: "/tmp/project-retry", | |
| 3123 | defaultModelSelection: modelSelection, | |
| 3124 | createdAt, | |
| 3125 | }); | |
| 3126 | ||
| 3127 | // First attempt: the thread gets a turn, a message, an activity, and a | |
| 3128 | // running session before its bootstrap fails and the server rolls back. | |
| 3129 | yield* createThread("cmd-retry-create-1", "First attempt"); | |
| 3130 | yield* engine.dispatch({ | |
| 3131 | type: "thread.turn.start", | |
| 3132 | commandId: CommandId.make("cmd-retry-turn-1"), | |
| 3133 | threadId, | |
| 3134 | message: { | |
| 3135 | messageId: MessageId.make("message-retry-1"), | |
| 3136 | role: "user", | |
| 3137 | text: "first attempt", | |
| 3138 | attachments: [], | |
| 3139 | }, | |
| 3140 | runtimeMode: "full-access", | |
| 3141 | interactionMode: "default", | |
| 3142 | createdAt, | |
| 3143 | }); | |
| 3144 | yield* engine.dispatch({ | |
| 3145 | type: "thread.activity.append", | |
| 3146 | commandId: CommandId.make("cmd-retry-activity-1"), | |
| 3147 | threadId, | |
| 3148 | activity: { | |
| 3149 | id: EventId.make("activity-retry-1"), | |
| 3150 | tone: "info", | |
| 3151 | kind: "approval.requested", | |
| 3152 | summary: "approval requested", | |
| 3153 | payload: { requestId: "request-retry-1" }, | |
| 3154 | turnId: null, | |
| 3155 | createdAt, | |
| 3156 | }, | |
| 3157 | createdAt, | |
| 3158 | }); | |
| 3159 | yield* engine.dispatch({ | |
| 3160 | type: "thread.proposed-plan.upsert", | |
| 3161 | commandId: CommandId.make("cmd-retry-plan-1"), | |
| 3162 | threadId, | |
| 3163 | proposedPlan: { | |
| 3164 | id: "plan-retry-1", | |
| 3165 | turnId: null, | |
| 3166 | planMarkdown: "# Plan", | |
| 3167 | implementedAt: null, | |
| 3168 | implementationThreadId: null, | |
| 3169 | createdAt, | |
| 3170 | updatedAt: createdAt, | |
| 3171 | }, | |
| 3172 | createdAt, | |
| 3173 | }); | |
| 3174 | yield* engine.dispatch({ | |
| 3175 | type: "thread.session.set", | |
| 3176 | commandId: CommandId.make("cmd-retry-session-1"), | |
| 3177 | threadId, | |
| 3178 | session: { | |
| 3179 | threadId, | |
| 3180 | status: "running", | |
| 3181 | providerName: "codex", | |
| 3182 | runtimeMode: "full-access", | |
| 3183 | activeTurnId: TurnId.make("turn-retry-1"), | |
| 3184 | lastError: null, | |
| 3185 | updatedAt: createdAt, | |
| 3186 | }, | |
| 3187 | createdAt, | |
| 3188 | }); | |
| 3189 | for (const table of perThreadTables) { | |
| 3190 | assert.isAbove(yield* countRowsForThread(table), 0, `${table} should be populated`); | |
| 3191 | } | |
| 3192 | const populatedShell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId)); | |
| 3193 | assert.isTrue(populatedShell.hasPendingApprovals); | |
| 3194 | assert.isTrue(populatedShell.hasActionableProposedPlan); | |
| 3195 | ||
| 3196 | yield* engine.dispatch({ | |
| 3197 | type: "thread.delete", | |
| 3198 | commandId: CommandId.make("cmd-retry-delete"), | |
| 3199 | threadId, | |
| 3200 | }); | |
| 3201 | assert.isTrue(Option.isNone(yield* snapshotQuery.getThreadShellById(threadId))); | |
| 3202 | ||
| 3203 | // Retry from the same draft reuses the thread id. | |
| 3204 | yield* createThread("cmd-retry-create-2", "Second attempt"); | |
| 3205 | ||
| 3206 | const shell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId)); | |
| 3207 | assert.strictEqual(shell.title, "Second attempt"); | |
| 3208 | assert.isFalse(shell.hasPendingApprovals); | |
| 3209 | assert.isFalse(shell.hasActionableProposedPlan); | |
| 3210 | for (const table of perThreadTables) { | |
| 3211 | assert.strictEqual(yield* countRowsForThread(table), 0, `${table} should be empty`); | |
| 3212 | } | |
| 3213 | const detail = Option.getOrThrow(yield* snapshotQuery.getThreadDetailById(threadId)); | |
| 3214 | assert.deepEqual(detail.messages, []); | |
| 3215 | assert.deepEqual(detail.activities, []); | |
| 3216 | assert.isNull(detail.latestTurn); | |
| 3217 | assert.isNull(detail.session); | |
| 3218 | }), | |
| 3219 | ); | |
| 2970 | 3220 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+51-1| ... | ... | @@ -871,7 +871,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 871 | 871 | } |
| 872 | 872 | |
| 873 | 873 | case "thread.deleted": { |
| 874 | attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); | |
| 874 | // A draft retry can re-create this id later in the log. During | |
| 875 | // replay the attachment files on disk already belong to that later | |
| 876 | // incarnation, so only an unsuperseded deletion removes them. | |
| 877 | const recreatedLater = yield* eventStore.hasEventAfter({ | |
| 878 | aggregateKind: "thread", | |
| 879 | aggregateId: event.payload.threadId, | |
| 880 | type: "thread.created", | |
| 881 | sequenceExclusive: event.sequence, | |
| 882 | }); | |
| 883 | if (!recreatedLater) { | |
| 884 | attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); | |
| 885 | } | |
| 875 | 886 | const existingRow = yield* projectionThreadRepository.getById({ |
| 876 | 887 | threadId: event.payload.threadId, |
| 877 | 888 | }); |
| ... | ... | @@ -987,6 +998,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 987 | 998 | "applyThreadMessagesProjection", |
| 988 | 999 | )(function* (event, attachmentSideEffects) { |
| 989 | 1000 | switch (event.type) { |
| 1001 | // A draft retry re-creates a soft-deleted thread id. Every projector | |
| 1002 | // drops its own rows for the old incarnation here so replay from any | |
| 1003 | // per-projector cursor rebuilds the new thread without stale history. | |
| 1004 | case "thread.created": | |
| 1005 | yield* projectionThreadMessageRepository.deleteByThreadId({ | |
| 1006 | threadId: event.payload.threadId, | |
| 1007 | }); | |
| 1008 | return; | |
| 1009 | ||
| 990 | 1010 | case "thread.message-sent": { |
| 991 | 1011 | const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ |
| 992 | 1012 | messageId: event.payload.messageId, |
| ... | ... | @@ -1066,6 +1086,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1066 | 1086 | "applyThreadProposedPlansProjection", |
| 1067 | 1087 | )(function* (event, _attachmentSideEffects) { |
| 1068 | 1088 | switch (event.type) { |
| 1089 | case "thread.created": | |
| 1090 | yield* projectionThreadProposedPlanRepository.deleteByThreadId({ | |
| 1091 | threadId: event.payload.threadId, | |
| 1092 | }); | |
| 1093 | return; | |
| 1094 | ||
| 1069 | 1095 | case "thread.proposed-plan-upserted": |
| 1070 | 1096 | yield* projectionThreadProposedPlanRepository.upsert({ |
| 1071 | 1097 | planId: event.payload.proposedPlan.id, |
| ... | ... | @@ -1117,6 +1143,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1117 | 1143 | "applyThreadActivitiesProjection", |
| 1118 | 1144 | )(function* (event, _attachmentSideEffects) { |
| 1119 | 1145 | switch (event.type) { |
| 1146 | case "thread.created": | |
| 1147 | yield* projectionThreadActivityRepository.deleteByThreadId({ | |
| 1148 | threadId: event.payload.threadId, | |
| 1149 | }); | |
| 1150 | return; | |
| 1151 | ||
| 1120 | 1152 | case "thread.activity-appended": |
| 1121 | 1153 | yield* projectionThreadActivityRepository.upsert({ |
| 1122 | 1154 | activityId: event.payload.activity.id, |
| ... | ... | @@ -1168,6 +1200,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1168 | 1200 | const applyThreadSessionsProjection: ProjectorDefinition["apply"] = Effect.fn( |
| 1169 | 1201 | "applyThreadSessionsProjection", |
| 1170 | 1202 | )(function* (event, _attachmentSideEffects) { |
| 1203 | if (event.type === "thread.created") { | |
| 1204 | yield* projectionThreadSessionRepository.deleteByThreadId({ | |
| 1205 | threadId: event.payload.threadId, | |
| 1206 | }); | |
| 1207 | return; | |
| 1208 | } | |
| 1171 | 1209 | if (event.type !== "thread.session-set") { |
| 1172 | 1210 | return; |
| 1173 | 1211 | } |
| ... | ... | @@ -1187,6 +1225,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1187 | 1225 | "applyThreadTurnsProjection", |
| 1188 | 1226 | )(function* (event, _attachmentSideEffects) { |
| 1189 | 1227 | switch (event.type) { |
| 1228 | case "thread.created": | |
| 1229 | yield* projectionTurnRepository.deleteByThreadId({ | |
| 1230 | threadId: event.payload.threadId, | |
| 1231 | }); | |
| 1232 | return; | |
| 1233 | ||
| 1190 | 1234 | case "thread.turn-start-requested": { |
| 1191 | 1235 | yield* projectionTurnRepository.replacePendingTurnStart({ |
| 1192 | 1236 | threadId: event.payload.threadId, |
| ... | ... | @@ -1524,6 +1568,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1524 | 1568 | "applyPendingApprovalsProjection", |
| 1525 | 1569 | )(function* (event, _attachmentSideEffects) { |
| 1526 | 1570 | switch (event.type) { |
| 1571 | case "thread.created": | |
| 1572 | yield* projectionPendingApprovalRepository.deleteByThreadId({ | |
| 1573 | threadId: event.payload.threadId, | |
| 1574 | }); | |
| 1575 | return; | |
| 1576 | ||
| 1527 | 1577 | case "thread.activity-appended": { |
| 1528 | 1578 | const requestId = |
| 1529 | 1579 | extractActivityRequestId(event.payload.activity.payload) ?? |
apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts+103-2| ... | ... | @@ -1,10 +1,35 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | CorrelationId, | |
| 4 | EventId, | |
| 5 | type OrchestrationEvent, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { it as effectIt } from "@effect/vitest"; | |
| 2 | 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Deferred from "effect/Deferred"; | |
| 3 | 11 | import * as Effect from "effect/Effect"; |
| 4 | 12 | import * as Exit from "effect/Exit"; |
| 13 | import * as Fiber from "effect/Fiber"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as Ref from "effect/Ref"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 5 | 17 | import { describe, expect, it } from "vite-plus/test"; |
| 6 | 18 | |
| 7 | import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; | |
| 19 | import { | |
| 20 | ProviderService, | |
| 21 | type ProviderServiceShape, | |
| 22 | } from "../../provider/Services/ProviderService.ts"; | |
| 23 | import * as TerminalManager from "../../terminal/Manager.ts"; | |
| 24 | import { | |
| 25 | OrchestrationEngineService, | |
| 26 | type OrchestrationEngineShape, | |
| 27 | } from "../Services/OrchestrationEngine.ts"; | |
| 28 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | |
| 29 | import { | |
| 30 | logCleanupCauseUnlessInterrupted, | |
| 31 | ThreadDeletionReactorLive, | |
| 32 | } from "./ThreadDeletionReactor.ts"; | |
| 8 | 33 | |
| 9 | 34 | describe("logCleanupCauseUnlessInterrupted", () => { |
| 10 | 35 | const threadId = ThreadId.make("thread-deletion-reactor-test"); |
| ... | ... | @@ -36,3 +61,79 @@ describe("logCleanupCauseUnlessInterrupted", () => { |
| 36 | 61 | } |
| 37 | 62 | }); |
| 38 | 63 | }); |
| 64 | ||
| 65 | describe("ThreadDeletionReactor drain", () => { | |
| 66 | const now = "2026-01-01T00:00:00.000Z"; | |
| 67 | const threadId = ThreadId.make("thread-deletion-reactor-drain"); | |
| 68 | const deletedEvent = (sequence: number): OrchestrationEvent => ({ | |
| 69 | sequence, | |
| 70 | eventId: EventId.make(`evt-deleted-${sequence}`), | |
| 71 | aggregateKind: "thread", | |
| 72 | aggregateId: threadId, | |
| 73 | type: "thread.deleted", | |
| 74 | occurredAt: now, | |
| 75 | commandId: CommandId.make(`cmd-deleted-${sequence}`), | |
| 76 | causationEventId: null, | |
| 77 | correlationId: CorrelationId.make(`cmd-deleted-${sequence}`), | |
| 78 | metadata: {}, | |
| 79 | payload: { threadId, deletedAt: now }, | |
| 80 | }); | |
| 81 | ||
| 82 | effectIt.effect("waits for a published deletion the subscriber has not consumed yet", () => | |
| 83 | Effect.gen(function* () { | |
| 84 | const stops: Array<number> = []; | |
| 85 | const firstCleanupDone = yield* Deferred.make<void>(); | |
| 86 | // The engine has already committed and published sequence 2, but the | |
| 87 | // subscriber has not received it yet: the stream releases it on demand. | |
| 88 | const releaseSecondEvent = yield* Deferred.make<void>(); | |
| 89 | const latestSequence = yield* Ref.make(0); | |
| 90 | const engine = { | |
| 91 | latestSequence: Ref.get(latestSequence), | |
| 92 | streamDomainEvents: Stream.concat( | |
| 93 | Stream.make(deletedEvent(1)), | |
| 94 | Stream.fromEffect(Deferred.await(releaseSecondEvent)).pipe( | |
| 95 | Stream.map(() => deletedEvent(2)), | |
| 96 | ), | |
| 97 | ), | |
| 98 | } as unknown as OrchestrationEngineShape; | |
| 99 | const providerService = { | |
| 100 | stopSession: () => | |
| 101 | Effect.gen(function* () { | |
| 102 | stops.push(stops.length + 1); | |
| 103 | if (stops.length === 1) { | |
| 104 | yield* Deferred.succeed(firstCleanupDone, undefined); | |
| 105 | } | |
| 106 | }), | |
| 107 | } as unknown as ProviderServiceShape; | |
| 108 | const terminalManager = { | |
| 109 | close: () => Effect.void, | |
| 110 | } as unknown as TerminalManager.TerminalManager["Service"]; | |
| 111 | const layer = ThreadDeletionReactorLive.pipe( | |
| 112 | Layer.provide(Layer.succeed(ProviderService, providerService)), | |
| 113 | Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminalManager)), | |
| 114 | Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), | |
| 115 | ); | |
| 116 | ||
| 117 | yield* Effect.scoped( | |
| 118 | Effect.gen(function* () { | |
| 119 | const reactor = yield* ThreadDeletionReactor; | |
| 120 | yield* reactor.start(); | |
| 121 | yield* Deferred.await(firstCleanupDone); | |
| 122 | ||
| 123 | // Sequence 1 is fully cleaned and the worker queue is idle. Sequence | |
| 124 | // 2 is committed and published but still in flight to the subscriber. | |
| 125 | yield* Ref.set(latestSequence, 2); | |
| 126 | const drained = yield* Effect.forkChild(reactor.drainThrough(2)); | |
| 127 | yield* Effect.yieldNow; | |
| 128 | yield* Effect.yieldNow; | |
| 129 | expect(stops).toEqual([1]); | |
| 130 | expect(drained.pollUnsafe()).toBeUndefined(); | |
| 131 | ||
| 132 | yield* Deferred.succeed(releaseSecondEvent, undefined); | |
| 133 | yield* Fiber.join(drained); | |
| 134 | expect(stops).toEqual([1, 2]); | |
| 135 | }), | |
| 136 | ).pipe(Effect.provide(layer)); | |
| 137 | }), | |
| 138 | ); | |
| 139 | }); |
apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts+31-7| ... | ... | @@ -4,6 +4,7 @@ import * as Cause from "effect/Cause"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Stream from "effect/Stream"; |
| 7 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 7 | 8 | |
| 8 | 9 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 9 | 10 | import * as TerminalManager from "../../terminal/Manager.ts"; |
| ... | ... | @@ -80,20 +81,43 @@ const make = Effect.gen(function* () { |
| 80 | 81 | |
| 81 | 82 | const worker = yield* makeDrainableWorker(processThreadDeletedSafely); |
| 82 | 83 | |
| 84 | // Highest event sequence the subscriber has handed to the worker. Waiting | |
| 85 | // through a successful thread.created sequence covers every deletion that | |
| 86 | // was ahead of that create in the engine queue; the worker drain then covers | |
| 87 | // the in-flight cleanup. | |
| 88 | const seenSequence = yield* SubscriptionRef.make(0); | |
| 89 | const noteSeen = (sequence: number) => | |
| 90 | SubscriptionRef.update(seenSequence, (seen) => Math.max(seen, sequence)); | |
| 91 | ||
| 83 | 92 | const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { |
| 84 | 93 | yield* forkParked( |
| 85 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | |
| 86 | if (event.type !== "thread.deleted") { | |
| 87 | return Effect.void; | |
| 88 | } | |
| 89 | return worker.enqueue(event); | |
| 90 | }), | |
| 94 | Stream.runForEach( | |
| 95 | orchestrationEngine.streamDomainEvents.pipe( | |
| 96 | // Events that landed before the subscription are not replayed, so | |
| 97 | // start the watermark at the current head instead of zero. | |
| 98 | Stream.onStart(orchestrationEngine.latestSequence.pipe(Effect.flatMap(noteSeen))), | |
| 99 | ), | |
| 100 | (event) => | |
| 101 | (event.type === "thread.deleted" ? worker.enqueue(event) : Effect.void).pipe( | |
| 102 | Effect.andThen(noteSeen(event.sequence)), | |
| 103 | ), | |
| 104 | ), | |
| 105 | ); | |
| 106 | }); | |
| 107 | ||
| 108 | const drainThrough: ThreadDeletionReactorShape["drainThrough"] = Effect.fn( | |
| 109 | "ThreadDeletionReactor.drainThrough", | |
| 110 | )(function* (target) { | |
| 111 | yield* SubscriptionRef.changes(seenSequence).pipe( | |
| 112 | Stream.filter((seen) => seen >= target), | |
| 113 | Stream.runHead, | |
| 91 | 114 | ); |
| 115 | yield* worker.drain; | |
| 92 | 116 | }); |
| 93 | 117 | |
| 94 | 118 | return { |
| 95 | 119 | start, |
| 96 | drain: worker.drain, | |
| 120 | drainThrough, | |
| 97 | 121 | } satisfies ThreadDeletionReactorShape; |
| 98 | 122 | }); |
| 99 | 123 |
apps/server/src/orchestration/Services/ThreadDeletionReactor.ts+5-3| ... | ... | @@ -23,10 +23,12 @@ export interface ThreadDeletionReactorShape { |
| 23 | 23 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; |
| 24 | 24 | |
| 25 | 25 | /** |
| 26 | * Resolves when the internal processing queue is empty and idle. | |
| 27 | * Intended for test use to replace timing-sensitive sleeps. | |
| 26 | * Resolves once every thread.deleted at or before the supplied event | |
| 27 | * sequence has been handed to the worker and the worker is empty and idle. | |
| 28 | * A successful thread.create sequence is the fence callers use before the | |
| 29 | * new incarnation can own runtime resources. | |
| 28 | 30 | */ |
| 29 | readonly drain: Effect.Effect<void>; | |
| 31 | readonly drainThrough: (sequence: number) => Effect.Effect<void>; | |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | /** |
apps/server/src/orchestration/commandInvariants.test.ts+30| ... | ... | @@ -199,4 +199,34 @@ describe("commandInvariants", () => { |
| 199 | 199 | ), |
| 200 | 200 | ).rejects.toThrow("already exists"); |
| 201 | 201 | }); |
| 202 | ||
| 203 | it("lets a draft retry re-create a thread id after its first attempt was deleted", async () => { | |
| 204 | const threadId = ThreadId.make("thread-1"); | |
| 205 | const firstAttempt = readModel.threads.find((thread) => thread.id === threadId)!; | |
| 206 | const afterRollback: OrchestrationReadModel = { | |
| 207 | ...readModel, | |
| 208 | threads: readModel.threads.map((thread) => | |
| 209 | thread.id === threadId ? { ...thread, deletedAt: now, updatedAt: now } : thread, | |
| 210 | ), | |
| 211 | }; | |
| 212 | const retry: OrchestrationCommand = { | |
| 213 | type: "thread.create", | |
| 214 | commandId: CommandId.make("cmd-retry"), | |
| 215 | threadId, | |
| 216 | projectId: firstAttempt.projectId, | |
| 217 | title: firstAttempt.title, | |
| 218 | modelSelection: firstAttempt.modelSelection, | |
| 219 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 220 | runtimeMode: "approval-required", | |
| 221 | branch: null, | |
| 222 | worktreePath: null, | |
| 223 | createdAt: now, | |
| 224 | }; | |
| 225 | ||
| 226 | await expect( | |
| 227 | Effect.runPromise( | |
| 228 | requireThreadAbsent({ readModel: afterRollback, command: retry, threadId }), | |
| 229 | ), | |
| 230 | ).resolves.toBeUndefined(); | |
| 231 | }); | |
| 202 | 232 | }); |
apps/server/src/orchestration/commandInvariants.ts+5-1| ... | ... | @@ -156,7 +156,11 @@ export function requireThreadAbsent(input: { |
| 156 | 156 | readonly command: OrchestrationCommand; |
| 157 | 157 | readonly threadId: ThreadId; |
| 158 | 158 | }): Effect.Effect<void, OrchestrationCommandInvariantError> { |
| 159 | if (!findThreadById(input.readModel, input.threadId)) { | |
| 159 | // Thread deletion is a soft delete and a draft keeps its client-minted id | |
| 160 | // across retries, so only a live row blocks creation. Projectors reset the | |
| 161 | // thread's rows when the id is created again. | |
| 162 | const existing = findThreadById(input.readModel, input.threadId); | |
| 163 | if (existing === undefined || existing.deletedAt !== null) { | |
| 160 | 164 | return Effect.void; |
| 161 | 165 | } |
| 162 | 166 | return Effect.fail( |
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+35| ... | ... | @@ -15,6 +15,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 15 | 15 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 16 | 16 | import * as Effect from "effect/Effect"; |
| 17 | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Option from "effect/Option"; | |
| 18 | 19 | import * as Schema from "effect/Schema"; |
| 19 | 20 | import * as Stream from "effect/Stream"; |
| 20 | 21 | |
| ... | ... | @@ -60,6 +61,13 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ |
| 60 | 61 | metadata: EventMetadataFromJsonString, |
| 61 | 62 | }); |
| 62 | 63 | |
| 64 | const HasEventAfterRequestSchema = Schema.Struct({ | |
| 65 | aggregateKind: Schema.String, | |
| 66 | aggregateId: Schema.String, | |
| 67 | type: Schema.String, | |
| 68 | sequenceExclusive: NonNegativeInt, | |
| 69 | }); | |
| 70 | ||
| 63 | 71 | const ReadFromSequenceRequestSchema = Schema.Struct({ |
| 64 | 72 | sequenceExclusive: NonNegativeInt, |
| 65 | 73 | limit: Schema.Number, |
| ... | ... | @@ -260,10 +268,37 @@ const makeEventStore = Effect.gen(function* () { |
| 260 | 268 | return readPage(sequenceExclusive, normalizedLimit); |
| 261 | 269 | }; |
| 262 | 270 | |
| 271 | const findEventAfter = SqlSchema.findOneOption({ | |
| 272 | Request: HasEventAfterRequestSchema, | |
| 273 | Result: Schema.Struct({ sequence: Schema.Number }), | |
| 274 | execute: (request) => | |
| 275 | sql` | |
| 276 | SELECT sequence | |
| 277 | FROM orchestration_events | |
| 278 | WHERE aggregate_kind = ${request.aggregateKind} | |
| 279 | AND stream_id = ${request.aggregateId} | |
| 280 | AND event_type = ${request.type} | |
| 281 | AND sequence > ${request.sequenceExclusive} | |
| 282 | LIMIT 1 | |
| 283 | `, | |
| 284 | }); | |
| 285 | ||
| 286 | const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => | |
| 287 | findEventAfter(input).pipe( | |
| 288 | Effect.map(Option.isSome), | |
| 289 | Effect.mapError( | |
| 290 | toPersistenceSqlOrDecodeError( | |
| 291 | "OrchestrationEventStore.hasEventAfter:query", | |
| 292 | "OrchestrationEventStore.hasEventAfter:decodeRow", | |
| 293 | ), | |
| 294 | ), | |
| 295 | ); | |
| 296 | ||
| 263 | 297 | return { |
| 264 | 298 | append, |
| 265 | 299 | readFromSequence, |
| 266 | 300 | readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), |
| 301 | hasEventAfter, | |
| 267 | 302 | } satisfies OrchestrationEventStoreShape; |
| 268 | 303 | }); |
| 269 | 304 |
apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts+17| ... | ... | @@ -95,6 +95,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { |
| 95 | 95 | `, |
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({ | |
| 99 | Request: ListProjectionPendingApprovalsInput, | |
| 100 | execute: ({ threadId }) => | |
| 101 | sql` | |
| 102 | DELETE FROM projection_pending_approvals | |
| 103 | WHERE thread_id = ${threadId} | |
| 104 | `, | |
| 105 | }); | |
| 106 | ||
| 98 | 107 | const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) => |
| 99 | 108 | upsertProjectionPendingApprovalRow(row).pipe( |
| 100 | 109 | Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), |
| ... | ... | @@ -123,11 +132,19 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { |
| 123 | 132 | ), |
| 124 | 133 | ); |
| 125 | 134 | |
| 135 | const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) => | |
| 136 | deleteProjectionPendingApprovalRowsByThread(input).pipe( | |
| 137 | Effect.mapError( | |
| 138 | toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByThreadId:query"), | |
| 139 | ), | |
| 140 | ); | |
| 141 | ||
| 126 | 142 | return { |
| 127 | 143 | upsert, |
| 128 | 144 | listByThreadId, |
| 129 | 145 | getByRequestId, |
| 130 | 146 | deleteByRequestId, |
| 147 | deleteByThreadId, | |
| 131 | 148 | } satisfies ProjectionPendingApprovalRepositoryShape; |
| 132 | 149 | }); |
| 133 | 150 |
apps/server/src/persistence/Services/OrchestrationEventStore.ts+13| ... | ... | @@ -52,6 +52,19 @@ export interface OrchestrationEventStoreShape { |
| 52 | 52 | * @returns Stream containing all stored events. |
| 53 | 53 | */ |
| 54 | 54 | readonly readAll: () => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>; |
| 55 | ||
| 56 | /** | |
| 57 | * Check whether an aggregate has an event of the given type after a sequence. | |
| 58 | * | |
| 59 | * Used during replay to tell whether a later event supersedes the one being | |
| 60 | * applied, without streaming the rest of the log. | |
| 61 | */ | |
| 62 | readonly hasEventAfter: (input: { | |
| 63 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; | |
| 64 | readonly aggregateId: string; | |
| 65 | readonly type: OrchestrationEvent["type"]; | |
| 66 | readonly sequenceExclusive: number; | |
| 67 | }) => Effect.Effect<boolean, OrchestrationEventStoreError>; | |
| 55 | 68 | } |
| 56 | 69 | |
| 57 | 70 | /** |
apps/server/src/persistence/Services/ProjectionPendingApprovals.ts+7| ... | ... | @@ -82,6 +82,13 @@ export interface ProjectionPendingApprovalRepositoryShape { |
| 82 | 82 | readonly deleteByRequestId: ( |
| 83 | 83 | input: DeleteProjectionPendingApprovalInput, |
| 84 | 84 | ) => Effect.Effect<void, ProjectionRepositoryError>; |
| 85 | ||
| 86 | /** | |
| 87 | * Delete every pending approval row for a thread. | |
| 88 | */ | |
| 89 | readonly deleteByThreadId: ( | |
| 90 | input: ListProjectionPendingApprovalsInput, | |
| 91 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 85 | 92 | } |
| 86 | 93 | |
| 87 | 94 | /** |
apps/server/src/server.test.ts+121-7| ... | ... | @@ -116,6 +116,7 @@ import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| 116 | 116 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 117 | 117 | import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; |
| 118 | 118 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 119 | import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; | |
| 119 | 120 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 120 | 121 | import { PersistenceSqlError } from "./persistence/Errors.ts"; |
| 121 | 122 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| ... | ... | @@ -411,6 +412,7 @@ const buildAppUnderTest = (options?: { |
| 411 | 412 | >; |
| 412 | 413 | terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>; |
| 413 | 414 | orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>; |
| 415 | threadDeletionReactor?: Partial<ThreadDeletionReactor["Service"]>; | |
| 414 | 416 | analyticsService?: Partial<AnalyticsService.AnalyticsService["Service"]>; |
| 415 | 417 | projectionSnapshotQuery?: Partial<ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]>; |
| 416 | 418 | checkpointDiffQuery?: Partial<CheckpointDiffQuery.CheckpointDiffQuery["Service"]>; |
| ... | ... | @@ -787,13 +789,20 @@ const buildAppUnderTest = (options?: { |
| 787 | 789 | ), |
| 788 | 790 | ), |
| 789 | 791 | Layer.provide( |
| 790 | Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ | |
| 791 | readEvents: () => Stream.empty, | |
| 792 | dispatch: () => Effect.succeed({ sequence: 0 }), | |
| 793 | streamDomainEvents: Stream.empty, | |
| 794 | latestSequence: Effect.succeed(0), | |
| 795 | ...options?.layers?.orchestrationEngine, | |
| 796 | }), | |
| 792 | Layer.mergeAll( | |
| 793 | Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ | |
| 794 | readEvents: () => Stream.empty, | |
| 795 | dispatch: () => Effect.succeed({ sequence: 0 }), | |
| 796 | streamDomainEvents: Stream.empty, | |
| 797 | latestSequence: Effect.succeed(0), | |
| 798 | ...options?.layers?.orchestrationEngine, | |
| 799 | }), | |
| 800 | Layer.mock(ThreadDeletionReactor)({ | |
| 801 | start: () => Effect.void, | |
| 802 | drainThrough: () => Effect.void, | |
| 803 | ...options?.layers?.threadDeletionReactor, | |
| 804 | }), | |
| 805 | ), | |
| 797 | 806 | ), |
| 798 | 807 | Layer.provide( |
| 799 | 808 | Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ |
| ... | ... | @@ -8476,6 +8485,111 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 8476 | 8485 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 8477 | 8486 | ); |
| 8478 | 8487 | |
| 8488 | it.effect("drains deletion cleanup through the re-created thread event", () => | |
| 8489 | Effect.gen(function* () { | |
| 8490 | // A draft retry reuses the thread id its failed bootstrap deleted. The | |
| 8491 | // deletion reactor stops sessions and closes terminals by that id, so | |
| 8492 | // both thread.create paths use the created event as a fence, then drain | |
| 8493 | // cleanup before handing the new incarnation to resource-owning work. | |
| 8494 | const trace: Array<string> = []; | |
| 8495 | const drainRequested = yield* Deferred.make<void>(); | |
| 8496 | const cleanupDone = yield* Deferred.make<void>(); | |
| 8497 | yield* buildAppUnderTest({ | |
| 8498 | layers: { | |
| 8499 | threadDeletionReactor: { | |
| 8500 | drainThrough: (sequence) => | |
| 8501 | Effect.gen(function* () { | |
| 8502 | trace.push(`drain:${sequence}`); | |
| 8503 | yield* Deferred.succeed(drainRequested, undefined); | |
| 8504 | yield* Deferred.await(cleanupDone); | |
| 8505 | }), | |
| 8506 | }, | |
| 8507 | orchestrationEngine: { | |
| 8508 | dispatch: (command) => | |
| 8509 | Effect.sync(() => { | |
| 8510 | trace.push(command.type); | |
| 8511 | return { sequence: trace.length }; | |
| 8512 | }), | |
| 8513 | readEvents: () => Stream.empty, | |
| 8514 | }, | |
| 8515 | }, | |
| 8516 | }); | |
| 8517 | ||
| 8518 | const createdAt = "2026-01-01T00:00:00.000Z"; | |
| 8519 | const threadId = ThreadId.make("thread-retry-after-delete"); | |
| 8520 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 8521 | ||
| 8522 | yield* Effect.scoped( | |
| 8523 | withWsRpcClient(wsUrl, (client) => | |
| 8524 | Effect.gen(function* () { | |
| 8525 | const directCreate = yield* Effect.forkChild( | |
| 8526 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | |
| 8527 | type: "thread.create", | |
| 8528 | commandId: CommandId.make("cmd-retry-create"), | |
| 8529 | threadId, | |
| 8530 | projectId: defaultProjectId, | |
| 8531 | title: "Retry", | |
| 8532 | modelSelection: defaultModelSelection, | |
| 8533 | runtimeMode: "full-access", | |
| 8534 | interactionMode: "default", | |
| 8535 | branch: null, | |
| 8536 | worktreePath: null, | |
| 8537 | createdAt, | |
| 8538 | }), | |
| 8539 | ); | |
| 8540 | yield* Deferred.await(drainRequested); | |
| 8541 | assert.deepEqual(trace, ["thread.create", "drain:1"]); | |
| 8542 | yield* Deferred.succeed(cleanupDone, undefined); | |
| 8543 | yield* Fiber.join(directCreate); | |
| 8544 | }), | |
| 8545 | ), | |
| 8546 | ); | |
| 8547 | assert.deepEqual(trace, ["thread.create", "drain:1"]); | |
| 8548 | ||
| 8549 | // Cleanup is already released; the bootstrap path must still drain | |
| 8550 | // between creating the thread and starting its turn. | |
| 8551 | trace.length = 0; | |
| 8552 | yield* Effect.scoped( | |
| 8553 | withWsRpcClient(wsUrl, (client) => | |
| 8554 | Effect.gen(function* () { | |
| 8555 | const bootstrapCreate = yield* Effect.forkChild( | |
| 8556 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | |
| 8557 | type: "thread.turn.start", | |
| 8558 | commandId: CommandId.make("cmd-retry-bootstrap"), | |
| 8559 | threadId, | |
| 8560 | message: { | |
| 8561 | messageId: MessageId.make("msg-retry-bootstrap"), | |
| 8562 | role: "user", | |
| 8563 | text: "hello", | |
| 8564 | attachments: [], | |
| 8565 | }, | |
| 8566 | modelSelection: defaultModelSelection, | |
| 8567 | runtimeMode: "full-access", | |
| 8568 | interactionMode: "default", | |
| 8569 | bootstrap: { | |
| 8570 | createThread: { | |
| 8571 | projectId: defaultProjectId, | |
| 8572 | title: "Retry", | |
| 8573 | modelSelection: defaultModelSelection, | |
| 8574 | runtimeMode: "full-access", | |
| 8575 | interactionMode: "default", | |
| 8576 | branch: null, | |
| 8577 | worktreePath: null, | |
| 8578 | createdAt, | |
| 8579 | }, | |
| 8580 | runSetupScript: false, | |
| 8581 | }, | |
| 8582 | createdAt, | |
| 8583 | }), | |
| 8584 | ); | |
| 8585 | yield* Fiber.join(bootstrapCreate); | |
| 8586 | }), | |
| 8587 | ), | |
| 8588 | ); | |
| 8589 | assert.deepEqual(trace, ["thread.create", "drain:1", "thread.turn.start"]); | |
| 8590 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 8591 | ); | |
| 8592 | ||
| 8479 | 8593 | it.effect("does not report a deleted bootstrap thread when cleanup fails", () => |
| 8480 | 8594 | Effect.gen(function* () { |
| 8481 | 8595 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
apps/server/src/ws.ts+16-1| ... | ... | @@ -85,6 +85,7 @@ import { |
| 85 | 85 | } from "./orchestration/Normalizer.ts"; |
| 86 | 86 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 87 | 87 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 88 | import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; | |
| 88 | 89 | import { |
| 89 | 90 | observeRpcEffect as instrumentRpcEffect, |
| 90 | 91 | observeRpcStream as instrumentRpcStream, |
| ... | ... | @@ -460,6 +461,7 @@ const makeWsRpcLayer = ( |
| 460 | 461 | const crypto = yield* Crypto.Crypto; |
| 461 | 462 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 462 | 463 | const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; |
| 464 | const threadDeletionReactor = yield* ThreadDeletionReactor; | |
| 463 | 465 | const analytics = yield* AnalyticsService.AnalyticsService; |
| 464 | 466 | // Every command dispatched on this connection carries the connecting |
| 465 | 467 | // client's origin, including server-generated bootstrap sub-commands: |
| ... | ... | @@ -1026,7 +1028,7 @@ const makeWsRpcLayer = ( |
| 1026 | 1028 | |
| 1027 | 1029 | const bootstrapProgram = Effect.gen(function* () { |
| 1028 | 1030 | if (bootstrap?.createThread) { |
| 1029 | yield* dispatchFromClient({ | |
| 1031 | const created = yield* dispatchFromClient({ | |
| 1030 | 1032 | type: "thread.create", |
| 1031 | 1033 | commandId: yield* serverCommandId("bootstrap-thread-create"), |
| 1032 | 1034 | threadId: command.threadId, |
| ... | ... | @@ -1039,6 +1041,11 @@ const makeWsRpcLayer = ( |
| 1039 | 1041 | worktreePath: bootstrap.createThread.worktreePath, |
| 1040 | 1042 | createdAt: bootstrap.createThread.createdAt, |
| 1041 | 1043 | }); |
| 1044 | // The successful create is a fence in the engine command queue: | |
| 1045 | // every delete for the prior incarnation committed before it. | |
| 1046 | // Drain through that event before setup or turn start can own | |
| 1047 | // terminals and provider sessions under the reused thread id. | |
| 1048 | yield* threadDeletionReactor.drainThrough(created.sequence); | |
| 1042 | 1049 | createdThread = true; |
| 1043 | 1050 | } |
| 1044 | 1051 | |
| ... | ... | @@ -1126,6 +1133,14 @@ const makeWsRpcLayer = ( |
| 1126 | 1133 | normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap |
| 1127 | 1134 | ? dispatchBootstrapTurnStart(normalizedCommand) |
| 1128 | 1135 | : dispatchFromClient(normalizedCommand).pipe( |
| 1136 | Effect.tap(({ sequence }) => | |
| 1137 | // Returning from thread.create is the handoff point at which | |
| 1138 | // clients may start resources for the new incarnation. Use | |
| 1139 | // its event sequence as the exact deletion-cleanup fence. | |
| 1140 | normalizedCommand.type === "thread.create" | |
| 1141 | ? threadDeletionReactor.drainThrough(sequence) | |
| 1142 | : Effect.void, | |
| 1143 | ), | |
| 1129 | 1144 | Effect.mapError((cause) => |
| 1130 | 1145 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), |
| 1131 | 1146 | ), |