| author | |
| committer | |
| log | 716069f40f000887cb01685099a16e19d9194307 |
| tree | c7381f01cf7e62fa6a53a819e9fb02e8972d9b9f |
| parent | 0e77fbd3d0d79eec5247e75583a04590a1785133 |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 368 insertions(+), 38 deletions(-)
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+7-4| ... | @@ -222,6 +222,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -222,6 +222,7 @@ describe("OrchestrationEngine", () => { |
| 222 | Layer.succeed(OrchestrationProjectionPipeline, { | 222 | Layer.succeed(OrchestrationProjectionPipeline, { |
| 223 | bootstrap: Effect.void, | 223 | bootstrap: Effect.void, |
| 224 | projectEvent: () => Effect.void, | 224 | projectEvent: () => Effect.void, |
| 225 | projectEventDeferred: () => Effect.succeed(Effect.void), | ||
| 225 | } satisfies OrchestrationProjectionPipelineShape), | 226 | } satisfies OrchestrationProjectionPipelineShape), |
| 226 | ), | 227 | ), |
| 227 | Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), | 228 | Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), |
| ... | @@ -1116,7 +1117,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -1116,7 +1117,8 @@ describe("OrchestrationEngine", () => { |
| 1116 | let shouldFailRequestedProjection = true; | 1117 | let shouldFailRequestedProjection = true; |
| 1117 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { | 1118 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { |
| 1118 | bootstrap: Effect.void, | 1119 | bootstrap: Effect.void, |
| 1119 | projectEvent: (event) => { | 1120 | projectEvent: () => Effect.void, |
| 1121 | projectEventDeferred: (event) => { | ||
| 1120 | if ( | 1122 | if ( |
| 1121 | shouldFailRequestedProjection && | 1123 | shouldFailRequestedProjection && |
| 1122 | event.commandId === CommandId.make("cmd-turn-start-atomic") && | 1124 | event.commandId === CommandId.make("cmd-turn-start-atomic") && |
| ... | @@ -1130,7 +1132,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -1130,7 +1132,7 @@ describe("OrchestrationEngine", () => { |
| 1130 | }), | 1132 | }), |
| 1131 | ); | 1133 | ); |
| 1132 | } | 1134 | } |
| 1133 | return Effect.void; | 1135 | return Effect.succeed(Effect.void); |
| 1134 | }, | 1136 | }, |
| 1135 | }; | 1137 | }; |
| 1136 | 1138 | ||
| ... | @@ -1263,7 +1265,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -1263,7 +1265,8 @@ describe("OrchestrationEngine", () => { |
| 1263 | let shouldFailProjection = true; | 1265 | let shouldFailProjection = true; |
| 1264 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { | 1266 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { |
| 1265 | bootstrap: Effect.void, | 1267 | bootstrap: Effect.void, |
| 1266 | projectEvent: (event) => { | 1268 | projectEvent: () => Effect.void, |
| 1269 | projectEventDeferred: (event) => { | ||
| 1267 | if ( | 1270 | if ( |
| 1268 | shouldFailProjection && | 1271 | shouldFailProjection && |
| 1269 | event.commandId === CommandId.make("cmd-thread-archive-sync-fail") | 1272 | event.commandId === CommandId.make("cmd-thread-archive-sync-fail") |
| ... | @@ -1276,7 +1279,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -1276,7 +1279,7 @@ describe("OrchestrationEngine", () => { |
| 1276 | }), | 1279 | }), |
| 1277 | ); | 1280 | ); |
| 1278 | } | 1281 | } |
| 1279 | return Effect.void; | 1282 | return Effect.succeed(Effect.void); |
| 1280 | }, | 1283 | }, |
| 1281 | }; | 1284 | }; |
| 1282 | 1285 |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+8-1| ... | @@ -224,12 +224,14 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -224,12 +224,14 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 224 | .withTransaction( | 224 | .withTransaction( |
| 225 | Effect.gen(function* () { | 225 | Effect.gen(function* () { |
| 226 | const committedEvents: OrchestrationEvent[] = []; | 226 | const committedEvents: OrchestrationEvent[] = []; |
| 227 | const attachmentCleanups: Effect.Effect<void>[] = []; | ||
| 227 | let nextCommandReadModel = commandReadModel; | 228 | let nextCommandReadModel = commandReadModel; |
| 228 | 229 | ||
| 229 | for (const nextEvent of eventBases) { | 230 | for (const nextEvent of eventBases) { |
| 230 | const savedEvent = yield* eventStore.append(nextEvent); | 231 | const savedEvent = yield* eventStore.append(nextEvent); |
| 231 | nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent); | 232 | nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent); |
| 232 | yield* projectionPipeline.projectEvent(savedEvent); | 233 | const cleanup = yield* projectionPipeline.projectEventDeferred(savedEvent); |
| 234 | attachmentCleanups.push(cleanup); | ||
| 233 | committedEvents.push(savedEvent); | 235 | committedEvents.push(savedEvent); |
| 234 | } | 236 | } |
| 235 | 237 | ||
| ... | @@ -253,6 +255,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -253,6 +255,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 253 | 255 | ||
| 254 | return { | 256 | return { |
| 255 | committedEvents, | 257 | committedEvents, |
| 258 | attachmentCleanups, | ||
| 256 | lastSequence: lastSavedEvent.sequence, | 259 | lastSequence: lastSavedEvent.sequence, |
| 257 | nextCommandReadModel, | 260 | nextCommandReadModel, |
| 258 | } as const; | 261 | } as const; |
| ... | @@ -267,6 +270,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -267,6 +270,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 267 | ); | 270 | ); |
| 268 | 271 | ||
| 269 | commandReadModel = committedCommand.nextCommandReadModel; | 272 | commandReadModel = committedCommand.nextCommandReadModel; |
| 273 | for (const cleanup of committedCommand.attachmentCleanups) { | ||
| 274 | yield* cleanup; | ||
| 275 | } | ||
| 270 | for (const [index, event] of committedCommand.committedEvents.entries()) { | 276 | for (const [index, event] of committedCommand.committedEvents.entries()) { |
| 271 | yield* PubSub.publish(eventPubSub, event); | 277 | yield* PubSub.publish(eventPubSub, event); |
| 272 | if (index === 0) { | 278 | if (index === 0) { |
| ... | @@ -381,6 +387,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -381,6 +387,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 381 | return { | 387 | return { |
| 382 | readEvents, | 388 | readEvents, |
| 383 | dispatch, | 389 | dispatch, |
| 390 | subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)), | ||
| 384 | // Each access creates a fresh PubSub subscription so that multiple | 391 | // Each access creates a fresh PubSub subscription so that multiple |
| 385 | // consumers (wsServer, ProviderRuntimeIngestion, CheckpointReactor, etc.) | 392 | // consumers (wsServer, ProviderRuntimeIngestion, CheckpointReactor, etc.) |
| 386 | // each independently receive all domain events. | 393 | // each independently receive all domain events. |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+177-2| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | CheckpointRef, | 2 | CheckpointRef, |
| 3 | CommandId, | 3 | CommandId, |
| 4 | CorrelationId, | 4 | CorrelationId, |
| 5 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 5 | EventId, | 6 | EventId, |
| 6 | MessageId, | 7 | MessageId, |
| 7 | ProjectId, | 8 | ProjectId, |
| ... | @@ -874,12 +875,13 @@ it.layer( | ... | @@ -874,12 +875,13 @@ it.layer( |
| 874 | it.layer( | 875 | it.layer( |
| 875 | Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), | 876 | Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), |
| 876 | )("OrchestrationProjectionPipeline", (it) => { | 877 | )("OrchestrationProjectionPipeline", (it) => { |
| 877 | it.effect("removes unreferenced attachment files when a thread is reverted", () => | 878 | it.effect("prunes reverted attachments only after every projector commits", () => |
| 878 | Effect.gen(function* () { | 879 | Effect.gen(function* () { |
| 879 | const fileSystem = yield* FileSystem.FileSystem; | 880 | const fileSystem = yield* FileSystem.FileSystem; |
| 880 | const path = yield* Path.Path; | 881 | const path = yield* Path.Path; |
| 881 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | 882 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 882 | const eventStore = yield* OrchestrationEventStore; | 883 | const eventStore = yield* OrchestrationEventStore; |
| 884 | const sql = yield* SqlClient.SqlClient; | ||
| 883 | const { attachmentsDir } = yield* ServerConfig; | 885 | const { attachmentsDir } = yield* ServerConfig; |
| 884 | const now = "2026-01-01T00:00:00.000Z"; | 886 | const now = "2026-01-01T00:00:00.000Z"; |
| 885 | const threadId = ThreadId.make("Thread Revert.Files"); | 887 | const threadId = ThreadId.make("Thread Revert.Files"); |
| ... | @@ -1067,7 +1069,7 @@ it.layer( | ... | @@ -1067,7 +1069,7 @@ it.layer( |
| 1067 | assert.isTrue(yield* exists(removePath)); | 1069 | assert.isTrue(yield* exists(removePath)); |
| 1068 | assert.isTrue(yield* exists(otherThreadPath)); | 1070 | assert.isTrue(yield* exists(otherThreadPath)); |
| 1069 | 1071 | ||
| 1070 | yield* appendAndProject({ | 1072 | const revertedEvent = yield* eventStore.append({ |
| 1071 | type: "thread.reverted", | 1073 | type: "thread.reverted", |
| 1072 | eventId: EventId.make("evt-revert-files-7"), | 1074 | eventId: EventId.make("evt-revert-files-7"), |
| 1073 | aggregateKind: "thread", | 1075 | aggregateKind: "thread", |
| ... | @@ -1083,9 +1085,75 @@ it.layer( | ... | @@ -1083,9 +1085,75 @@ it.layer( |
| 1083 | }, | 1085 | }, |
| 1084 | }); | 1086 | }); |
| 1085 | 1087 | ||
| 1088 | yield* sql` | ||
| 1089 | CREATE TRIGGER fail_revert_projection | ||
| 1090 | BEFORE UPDATE ON projection_state | ||
| 1091 | WHEN NEW.projector = 'projection.threads' | ||
| 1092 | BEGIN | ||
| 1093 | SELECT RAISE(FAIL, 'forced later projector failure'); | ||
| 1094 | END | ||
| 1095 | `; | ||
| 1096 | const projectionError = yield* projectionPipeline | ||
| 1097 | .projectEvent(revertedEvent) | ||
| 1098 | .pipe(Effect.flip); | ||
| 1099 | assert.equal(projectionError._tag, "PersistenceSqlError"); | ||
| 1100 | assert.isTrue(yield* exists(removePath)); | ||
| 1101 | const rolledBackMessages = yield* sql<{ readonly messageId: string }>` | ||
| 1102 | SELECT message_id AS "messageId" FROM projection_thread_messages | ||
| 1103 | WHERE message_id = 'message-remove' | ||
| 1104 | `; | ||
| 1105 | assert.deepEqual(rolledBackMessages, [{ messageId: "message-remove" }]); | ||
| 1106 | yield* sql`DROP TRIGGER fail_revert_projection`; | ||
| 1107 | |||
| 1108 | const laterAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000005"; | ||
| 1109 | const laterPath = path.join(attachmentsDir, `${laterAttachmentId}.png`); | ||
| 1110 | yield* fileSystem.writeFileString(laterPath, "added after revert"); | ||
| 1111 | const cleanup = yield* sql.withTransaction( | ||
| 1112 | Effect.gen(function* () { | ||
| 1113 | const cleanup = yield* projectionPipeline.projectEventDeferred(revertedEvent); | ||
| 1114 | yield* appendAndProject({ | ||
| 1115 | type: "thread.message-sent", | ||
| 1116 | eventId: EventId.make("evt-revert-files-later"), | ||
| 1117 | aggregateKind: "thread", | ||
| 1118 | aggregateId: threadId, | ||
| 1119 | occurredAt: now, | ||
| 1120 | commandId: CommandId.make("cmd-revert-files-later"), | ||
| 1121 | causationEventId: null, | ||
| 1122 | correlationId: CorrelationId.make("cmd-revert-files-later"), | ||
| 1123 | metadata: {}, | ||
| 1124 | payload: { | ||
| 1125 | threadId, | ||
| 1126 | messageId: MessageId.make("message-later"), | ||
| 1127 | role: "user", | ||
| 1128 | text: "Later attachment", | ||
| 1129 | attachments: [ | ||
| 1130 | { | ||
| 1131 | type: "image", | ||
| 1132 | id: laterAttachmentId, | ||
| 1133 | name: "later.png", | ||
| 1134 | mimeType: "image/png", | ||
| 1135 | sizeBytes: 5, | ||
| 1136 | }, | ||
| 1137 | ], | ||
| 1138 | turnId: null, | ||
| 1139 | streaming: false, | ||
| 1140 | createdAt: now, | ||
| 1141 | updatedAt: now, | ||
| 1142 | }, | ||
| 1143 | }); | ||
| 1144 | assert.isTrue(yield* exists(removePath)); | ||
| 1145 | // Return the cleanup effect so the caller runs it after the outer transaction commits. | ||
| 1146 | // @effect-diagnostics-next-line returnEffectInGen:off | ||
| 1147 | return cleanup; | ||
| 1148 | }), | ||
| 1149 | ); | ||
| 1150 | assert.isTrue(yield* exists(removePath)); | ||
| 1151 | yield* cleanup; | ||
| 1152 | |||
| 1086 | assert.isTrue(yield* exists(keepPath)); | 1153 | assert.isTrue(yield* exists(keepPath)); |
| 1087 | assert.isTrue(yield* exists(keepFilePath)); | 1154 | assert.isTrue(yield* exists(keepFilePath)); |
| 1088 | assert.isFalse(yield* exists(removePath)); | 1155 | assert.isFalse(yield* exists(removePath)); |
| 1156 | assert.isTrue(yield* exists(laterPath)); | ||
| 1089 | assert.isTrue(yield* exists(otherThreadPath)); | 1157 | assert.isTrue(yield* exists(otherThreadPath)); |
| 1090 | }), | 1158 | }), |
| 1091 | ); | 1159 | ); |
| ... | @@ -3522,4 +3590,111 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -3522,4 +3590,111 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 3522 | assert.isNull(detail.session); | 3590 | assert.isNull(detail.session); |
| 3523 | }), | 3591 | }), |
| 3524 | ); | 3592 | ); |
| 3593 | |||
| 3594 | it.effect("cleans attachments only after the command receipt commits", () => | ||
| 3595 | Effect.gen(function* () { | ||
| 3596 | const engine = yield* OrchestrationEngineService; | ||
| 3597 | const sql = yield* SqlClient.SqlClient; | ||
| 3598 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 3599 | const path = yield* Path.Path; | ||
| 3600 | const { attachmentsDir } = yield* ServerConfig; | ||
| 3601 | const createdAt = "2026-01-01T00:00:00.000Z"; | ||
| 3602 | const projectId = ProjectId.make("project-outer-rollback"); | ||
| 3603 | const threadId = ThreadId.make("thread-outer-rollback"); | ||
| 3604 | const cleanupFailureThreadId = ThreadId.make("thread-cleanup-failure"); | ||
| 3605 | const commandId = CommandId.make("cmd-outer-rollback-delete"); | ||
| 3606 | const attachmentPath = path.join( | ||
| 3607 | attachmentsDir, | ||
| 3608 | "thread-outer-rollback-00000000-0000-4000-8000-000000000001.png", | ||
| 3609 | ); | ||
| 3610 | const blockedAttachmentPath = path.join( | ||
| 3611 | attachmentsDir, | ||
| 3612 | "thread-cleanup-failure-00000000-0000-4000-8000-000000000001.png", | ||
| 3613 | ); | ||
| 3614 | |||
| 3615 | yield* engine.dispatch({ | ||
| 3616 | type: "project.create", | ||
| 3617 | commandId: CommandId.make("cmd-outer-rollback-project"), | ||
| 3618 | projectId, | ||
| 3619 | title: "Outer rollback project", | ||
| 3620 | workspaceRoot: "/tmp/project-outer-rollback", | ||
| 3621 | createdAt, | ||
| 3622 | }); | ||
| 3623 | for (const id of [threadId, cleanupFailureThreadId]) { | ||
| 3624 | yield* engine.dispatch({ | ||
| 3625 | type: "thread.create", | ||
| 3626 | commandId: CommandId.make(`cmd-create-${id}`), | ||
| 3627 | threadId: id, | ||
| 3628 | projectId, | ||
| 3629 | title: "Attachment cleanup thread", | ||
| 3630 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 3631 | modelSelection: { | ||
| 3632 | instanceId: ProviderInstanceId.make("codex"), | ||
| 3633 | model: "gpt-5-codex", | ||
| 3634 | }, | ||
| 3635 | runtimeMode: "full-access", | ||
| 3636 | branch: null, | ||
| 3637 | worktreePath: null, | ||
| 3638 | createdAt, | ||
| 3639 | }); | ||
| 3640 | } | ||
| 3641 | |||
| 3642 | yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); | ||
| 3643 | yield* fileSystem.writeFileString(attachmentPath, "keep this attachment"); | ||
| 3644 | yield* sql` | ||
| 3645 | CREATE TRIGGER fail_attachment_command_receipt | ||
| 3646 | BEFORE INSERT ON orchestration_command_receipts | ||
| 3647 | WHEN NEW.command_id = 'cmd-outer-rollback-delete' AND NEW.status = 'accepted' | ||
| 3648 | BEGIN | ||
| 3649 | SELECT RAISE(FAIL, 'forced receipt failure'); | ||
| 3650 | END | ||
| 3651 | `; | ||
| 3652 | const deleteCommand = { type: "thread.delete", commandId, threadId } as const; | ||
| 3653 | const dispatchError = yield* engine.dispatch(deleteCommand).pipe(Effect.flip); | ||
| 3654 | assert.equal(dispatchError._tag, "PersistenceSqlError"); | ||
| 3655 | assert.equal(yield* fileSystem.readFileString(attachmentPath), "keep this attachment"); | ||
| 3656 | const rolledBackThreads = yield* sql<{ readonly deletedAt: string | null }>` | ||
| 3657 | SELECT deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} | ||
| 3658 | `; | ||
| 3659 | assert.deepEqual(rolledBackThreads, [{ deletedAt: null }]); | ||
| 3660 | const rolledBackEvents = yield* sql` | ||
| 3661 | SELECT sequence FROM orchestration_events WHERE command_id = ${commandId} | ||
| 3662 | `; | ||
| 3663 | assert.deepEqual(rolledBackEvents, []); | ||
| 3664 | const rolledBackReceipts = yield* sql` | ||
| 3665 | SELECT status FROM orchestration_command_receipts WHERE command_id = ${commandId} | ||
| 3666 | `; | ||
| 3667 | assert.deepEqual(rolledBackReceipts, []); | ||
| 3668 | yield* sql`DROP TRIGGER fail_attachment_command_receipt`; | ||
| 3669 | |||
| 3670 | const result = yield* engine.dispatch(deleteCommand); | ||
| 3671 | assert.isFalse(yield* exists(attachmentPath)); | ||
| 3672 | const committedReceipts = yield* sql<{ | ||
| 3673 | readonly status: string; | ||
| 3674 | readonly resultSequence: number; | ||
| 3675 | }>` | ||
| 3676 | SELECT status, result_sequence AS "resultSequence" | ||
| 3677 | FROM orchestration_command_receipts WHERE command_id = ${commandId} | ||
| 3678 | `; | ||
| 3679 | assert.deepEqual(committedReceipts, [ | ||
| 3680 | { status: "accepted", resultSequence: result.sequence }, | ||
| 3681 | ]); | ||
| 3682 | |||
| 3683 | // Removing a nonempty directory as a file fails after the command commits. | ||
| 3684 | yield* fileSystem.makeDirectory(blockedAttachmentPath); | ||
| 3685 | yield* fileSystem.writeFileString(path.join(blockedAttachmentPath, "keep.txt"), "keep"); | ||
| 3686 | const cleanupFailureCommandId = CommandId.make("cmd-cleanup-failure-delete"); | ||
| 3687 | yield* engine.dispatch({ | ||
| 3688 | type: "thread.delete", | ||
| 3689 | commandId: cleanupFailureCommandId, | ||
| 3690 | threadId: cleanupFailureThreadId, | ||
| 3691 | }); | ||
| 3692 | assert.isTrue(yield* exists(blockedAttachmentPath)); | ||
| 3693 | const cleanupFailureReceipts = yield* sql<{ readonly status: string }>` | ||
| 3694 | SELECT status FROM orchestration_command_receipts | ||
| 3695 | WHERE command_id = ${cleanupFailureCommandId} | ||
| 3696 | `; | ||
| 3697 | assert.deepEqual(cleanupFailureReceipts, [{ status: "accepted" }]); | ||
| 3698 | }), | ||
| 3699 | ); | ||
| 3525 | }); | 3700 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+92-27| ... | @@ -1765,6 +1765,70 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1765,6 +1765,70 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1765 | }, | 1765 | }, |
| 1766 | ]; | 1766 | ]; |
| 1767 | 1767 | ||
| 1768 | const applyAttachmentSideEffects = Effect.fn("applyAttachmentSideEffects")( | ||
| 1769 | function* (event: OrchestrationEvent, sideEffects: AttachmentSideEffects) { | ||
| 1770 | if ( | ||
| 1771 | sideEffects.deletedThreadIds.size === 0 && | ||
| 1772 | sideEffects.prunedThreadRelativePaths.size === 0 | ||
| 1773 | ) { | ||
| 1774 | return; | ||
| 1775 | } | ||
| 1776 | |||
| 1777 | const deletedThreadIds = new Set<string>(); | ||
| 1778 | for (const threadId of sideEffects.deletedThreadIds) { | ||
| 1779 | const recreatedLater = yield* eventStore.hasEventAfter({ | ||
| 1780 | aggregateKind: "thread", | ||
| 1781 | aggregateId: ThreadId.make(threadId), | ||
| 1782 | type: "thread.created", | ||
| 1783 | sequenceExclusive: event.sequence, | ||
| 1784 | }); | ||
| 1785 | if (!recreatedLater) { | ||
| 1786 | deletedThreadIds.add(threadId); | ||
| 1787 | } | ||
| 1788 | } | ||
| 1789 | |||
| 1790 | // Later events in the same transaction can add attachment references. | ||
| 1791 | const prunedThreadRelativePaths = new Map<string, Set<string>>(); | ||
| 1792 | for (const threadId of sideEffects.prunedThreadRelativePaths.keys()) { | ||
| 1793 | const messages = yield* projectionThreadMessageRepository.listByThreadId({ | ||
| 1794 | threadId: ThreadId.make(threadId), | ||
| 1795 | }); | ||
| 1796 | prunedThreadRelativePaths.set( | ||
| 1797 | threadId, | ||
| 1798 | collectThreadAttachmentRelativePaths(threadId, messages), | ||
| 1799 | ); | ||
| 1800 | } | ||
| 1801 | |||
| 1802 | yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths }); | ||
| 1803 | }, | ||
| 1804 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 1805 | Effect.provideService(Path.Path, path), | ||
| 1806 | Effect.provideService(ServerConfig, serverConfig), | ||
| 1807 | (effect, event) => | ||
| 1808 | effect.pipe( | ||
| 1809 | Effect.catch((cause) => | ||
| 1810 | Effect.logWarning("failed to apply projected attachment side-effects", { | ||
| 1811 | sequence: event.sequence, | ||
| 1812 | eventType: event.type, | ||
| 1813 | cause, | ||
| 1814 | }), | ||
| 1815 | ), | ||
| 1816 | ), | ||
| 1817 | ); | ||
| 1818 | |||
| 1819 | const applyProjectorForEvent = Effect.fn("applyProjectorForEvent")(function* ( | ||
| 1820 | projector: ProjectorDefinition, | ||
| 1821 | event: OrchestrationEvent, | ||
| 1822 | attachmentSideEffects: AttachmentSideEffects, | ||
| 1823 | ) { | ||
| 1824 | yield* projector.apply(event, attachmentSideEffects); | ||
| 1825 | yield* projectionStateRepository.upsert({ | ||
| 1826 | projector: projector.name, | ||
| 1827 | lastAppliedSequence: event.sequence, | ||
| 1828 | updatedAt: event.occurredAt, | ||
| 1829 | }); | ||
| 1830 | }); | ||
| 1831 | |||
| 1768 | const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( | 1832 | const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( |
| 1769 | projector: ProjectorDefinition, | 1833 | projector: ProjectorDefinition, |
| 1770 | event: OrchestrationEvent, | 1834 | event: OrchestrationEvent, |
| ... | @@ -1774,28 +1838,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1774,28 +1838,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1774 | prunedThreadRelativePaths: new Map<string, Set<string>>(), | 1838 | prunedThreadRelativePaths: new Map<string, Set<string>>(), |
| 1775 | }; | 1839 | }; |
| 1776 | 1840 | ||
| 1777 | yield* sql.withTransaction( | 1841 | yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects)); |
| 1778 | projector.apply(event, attachmentSideEffects).pipe( | 1842 | yield* applyAttachmentSideEffects(event, attachmentSideEffects); |
| 1779 | Effect.flatMap(() => | ||
| 1780 | projectionStateRepository.upsert({ | ||
| 1781 | projector: projector.name, | ||
| 1782 | lastAppliedSequence: event.sequence, | ||
| 1783 | updatedAt: event.occurredAt, | ||
| 1784 | }), | ||
| 1785 | ), | ||
| 1786 | ), | ||
| 1787 | ); | ||
| 1788 | |||
| 1789 | yield* runAttachmentSideEffects(attachmentSideEffects).pipe( | ||
| 1790 | Effect.catch((cause) => | ||
| 1791 | Effect.logWarning("failed to apply projected attachment side-effects", { | ||
| 1792 | projector: projector.name, | ||
| 1793 | sequence: event.sequence, | ||
| 1794 | eventType: event.type, | ||
| 1795 | cause, | ||
| 1796 | }), | ||
| 1797 | ), | ||
| 1798 | ); | ||
| 1799 | }); | 1843 | }); |
| 1800 | 1844 | ||
| 1801 | const bootstrapProjector = (projector: ProjectorDefinition) => | 1845 | const bootstrapProjector = (projector: ProjectorDefinition) => |
| ... | @@ -1815,19 +1859,39 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1815,19 +1859,39 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1815 | ), | 1859 | ), |
| 1816 | ); | 1860 | ); |
| 1817 | 1861 | ||
| 1818 | const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => | 1862 | const projectEventDeferred: OrchestrationProjectionPipelineShape["projectEventDeferred"] = |
| 1819 | Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { | 1863 | Effect.fn("projectEventDeferred")( |
| 1820 | concurrency: 1, | 1864 | function* (event) { |
| 1821 | }).pipe( | 1865 | const attachmentSideEffects: AttachmentSideEffects = { |
| 1866 | deletedThreadIds: new Set<string>(), | ||
| 1867 | prunedThreadRelativePaths: new Map<string, Set<string>>(), | ||
| 1868 | }; | ||
| 1869 | yield* sql.withTransaction( | ||
| 1870 | Effect.forEach( | ||
| 1871 | projectors, | ||
| 1872 | (projector) => applyProjectorForEvent(projector, event, attachmentSideEffects), | ||
| 1873 | { concurrency: 1, discard: true }, | ||
| 1874 | ), | ||
| 1875 | ); | ||
| 1876 | // Return the cleanup effect so the caller runs it after the outer transaction commits. | ||
| 1877 | // @effect-diagnostics-next-line returnEffectInGen:off | ||
| 1878 | return applyAttachmentSideEffects(event, attachmentSideEffects); | ||
| 1879 | }, | ||
| 1822 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 1880 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 1823 | Effect.provideService(Path.Path, path), | 1881 | Effect.provideService(Path.Path, path), |
| 1824 | Effect.provideService(ServerConfig, serverConfig), | 1882 | Effect.provideService(ServerConfig, serverConfig), |
| 1825 | Effect.asVoid, | ||
| 1826 | Effect.catchTag("SqlError", (sqlError) => | 1883 | Effect.catchTag("SqlError", (sqlError) => |
| 1827 | Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), | 1884 | Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), |
| 1828 | ), | 1885 | ), |
| 1829 | ); | 1886 | ); |
| 1830 | 1887 | ||
| 1888 | const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = Effect.fn( | ||
| 1889 | "projectEvent", | ||
| 1890 | )(function* (event) { | ||
| 1891 | const cleanup = yield* projectEventDeferred(event); | ||
| 1892 | yield* cleanup; | ||
| 1893 | }); | ||
| 1894 | |||
| 1831 | const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( | 1895 | const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( |
| 1832 | projectors, | 1896 | projectors, |
| 1833 | bootstrapProjector, | 1897 | bootstrapProjector, |
| ... | @@ -1850,6 +1914,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1850,6 +1914,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1850 | return { | 1914 | return { |
| 1851 | bootstrap, | 1915 | bootstrap, |
| 1852 | projectEvent, | 1916 | projectEvent, |
| 1917 | projectEventDeferred, | ||
| 1853 | } satisfies OrchestrationProjectionPipelineShape; | 1918 | } satisfies OrchestrationProjectionPipelineShape; |
| 1854 | }, | 1919 | }, |
| 1855 | ); | 1920 | ); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+50-1| ... | @@ -61,6 +61,7 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" | ... | @@ -61,6 +61,7 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" |
| 61 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 61 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 62 | import * as Clock from "effect/Clock"; | 62 | import * as Clock from "effect/Clock"; |
| 63 | import { ServerSettingsService } from "../../serverSettings.ts"; | 63 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 64 | import { ServerActivation } from "../../serverActivation.ts"; | ||
| 64 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | 65 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 65 | import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; | 66 | import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; |
| 66 | 67 | ||
| ... | @@ -150,6 +151,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -150,6 +151,7 @@ describe("ProviderCommandReactor", () => { |
| 150 | readonly requiresNewThreadForModelChange?: boolean; | 151 | readonly requiresNewThreadForModelChange?: boolean; |
| 151 | readonly titleRegenerationCompletionDispatchFailures?: number; | 152 | readonly titleRegenerationCompletionDispatchFailures?: number; |
| 152 | readonly titleRegenerationBeforeStart?: "one" | "two"; | 153 | readonly titleRegenerationBeforeStart?: "one" | "two"; |
| 154 | readonly serverActivation?: Effect.Effect<void>; | ||
| 153 | readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; | 155 | readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 154 | readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; | 156 | readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 155 | readonly startSessionEffect?: ( | 157 | readonly startSessionEffect?: ( |
| ... | @@ -395,6 +397,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -395,6 +397,7 @@ describe("ProviderCommandReactor", () => { |
| 395 | get streamDomainEvents() { | 397 | get streamDomainEvents() { |
| 396 | return engine.streamDomainEvents; | 398 | return engine.streamDomainEvents; |
| 397 | }, | 399 | }, |
| 400 | subscribeDomainEvents: engine.subscribeDomainEvents, | ||
| 398 | latestSequence: engine.latestSequence, | 401 | latestSequence: engine.latestSequence, |
| 399 | } satisfies OrchestrationEngineService["Service"]; | 402 | } satisfies OrchestrationEngineService["Service"]; |
| 400 | }), | 403 | }), |
| ... | @@ -500,7 +503,14 @@ describe("ProviderCommandReactor", () => { | ... | @@ -500,7 +503,14 @@ describe("ProviderCommandReactor", () => { |
| 500 | } | 503 | } |
| 501 | 504 | ||
| 502 | scope = await Effect.runPromise(Scope.make("sequential")); | 505 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 503 | await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); | 506 | await Effect.runPromise( |
| 507 | reactor | ||
| 508 | .start() | ||
| 509 | .pipe( | ||
| 510 | Scope.provide(scope), | ||
| 511 | Effect.provideService(ServerActivation, input?.serverActivation), | ||
| 512 | ), | ||
| 513 | ); | ||
| 504 | const drain = () => Effect.runPromise(reactor.drain); | 514 | const drain = () => Effect.runPromise(reactor.drain); |
| 505 | 515 | ||
| 506 | return { | 516 | return { |
| ... | @@ -568,6 +578,45 @@ describe("ProviderCommandReactor", () => { | ... | @@ -568,6 +578,45 @@ describe("ProviderCommandReactor", () => { |
| 568 | expect(thread?.session?.runtimeMode).toBe("approval-required"); | 578 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 569 | }); | 579 | }); |
| 570 | 580 | ||
| 581 | effectIt.effect("retains a turn dispatched immediately after start until activation", () => | ||
| 582 | Effect.gen(function* () { | ||
| 583 | const activation = yield* Deferred.make<void>(); | ||
| 584 | const started = yield* Deferred.make<ProviderSession>(); | ||
| 585 | const harness = yield* Effect.promise(() => | ||
| 586 | createHarness({ | ||
| 587 | serverActivation: Deferred.await(activation), | ||
| 588 | startSessionEffect: (session) => | ||
| 589 | Deferred.succeed(started, session).pipe(Effect.as(session)), | ||
| 590 | }), | ||
| 591 | ); | ||
| 592 | |||
| 593 | yield* harness.engine.dispatch({ | ||
| 594 | type: "thread.turn.start", | ||
| 595 | commandId: CommandId.make("cmd-turn-start-before-activation"), | ||
| 596 | threadId: ThreadId.make("thread-1"), | ||
| 597 | message: { | ||
| 598 | messageId: MessageId.make("message-before-activation"), | ||
| 599 | role: "user", | ||
| 600 | text: "Start after activation", | ||
| 601 | attachments: [], | ||
| 602 | }, | ||
| 603 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 604 | runtimeMode: "approval-required", | ||
| 605 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 606 | }); | ||
| 607 | expect(yield* Deferred.isDone(started)).toBe(false); | ||
| 608 | |||
| 609 | yield* Deferred.succeed(activation, undefined); | ||
| 610 | const session = yield* Deferred.await(started); | ||
| 611 | yield* Effect.promise(() => harness.drain()); | ||
| 612 | expect(session.threadId).toBe(ThreadId.make("thread-1")); | ||
| 613 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | ||
| 614 | threadId: ThreadId.make("thread-1"), | ||
| 615 | input: "Start after activation", | ||
| 616 | }); | ||
| 617 | }), | ||
| 618 | ); | ||
| 619 | |||
| 571 | effectIt.effect("projects starting before a slow provider session finishes", () => | 620 | effectIt.effect("projects starting before a slow provider session finishes", () => |
| 572 | Effect.gen(function* () { | 621 | Effect.gen(function* () { |
| 573 | const releaseStart = yield* Deferred.make<void>(); | 622 | const releaseStart = yield* Deferred.make<void>(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3-1| ... | @@ -1554,7 +1554,9 @@ const make = Effect.gen(function* () { | ... | @@ -1554,7 +1554,9 @@ const make = Effect.gen(function* () { |
| 1554 | } | 1554 | } |
| 1555 | }); | 1555 | }); |
| 1556 | 1556 | ||
| 1557 | yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent)); | 1557 | // Subscribe before returning, even while event handling waits for server activation. |
| 1558 | const domainEvents = yield* orchestrationEngine.subscribeDomainEvents; | ||
| 1559 | yield* forkParked(Stream.runForEach(domainEvents, processEvent)); | ||
| 1558 | 1560 | ||
| 1559 | // The domain event stream is hot, so work pending before this reactor | 1561 | // The domain event stream is hot, so work pending before this reactor |
| 1560 | // starts cannot be resumed. Correlated completions only clear the request | 1562 | // starts cannot be resumed. Correlated completions only clear the request |
apps/server/src/orchestration/Services/OrchestrationEngine.ts+11| ... | @@ -17,6 +17,7 @@ import type { | ... | @@ -17,6 +17,7 @@ import type { |
| 17 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 18 | import * as Context from "effect/Context"; | 18 | import * as Context from "effect/Context"; |
| 19 | import type * as Effect from "effect/Effect"; | 19 | import type * as Effect from "effect/Effect"; |
| 20 | import type * as Scope from "effect/Scope"; | ||
| 20 | import type * as Stream from "effect/Stream"; | 21 | import type * as Stream from "effect/Stream"; |
| 21 | 22 | ||
| 22 | import type { OrchestrationDispatchError } from "../Errors.ts"; | 23 | import type { OrchestrationDispatchError } from "../Errors.ts"; |
| ... | @@ -64,6 +65,16 @@ export interface OrchestrationEngineShape { | ... | @@ -64,6 +65,16 @@ export interface OrchestrationEngineShape { |
| 64 | */ | 65 | */ |
| 65 | readonly streamDomainEvents: Stream.Stream<OrchestrationEvent>; | 66 | readonly streamDomainEvents: Stream.Stream<OrchestrationEvent>; |
| 66 | 67 | ||
| 68 | /** | ||
| 69 | * Acquire a domain-event subscription before starting a consumer. | ||
| 70 | * The subscription is ready when this effect returns and closes with the scope. | ||
| 71 | */ | ||
| 72 | readonly subscribeDomainEvents: Effect.Effect< | ||
| 73 | Stream.Stream<OrchestrationEvent>, | ||
| 74 | never, | ||
| 75 | Scope.Scope | ||
| 76 | >; | ||
| 77 | |||
| 67 | /** | 78 | /** |
| 68 | * The latest sequence reflected in the engine's authoritative command read | 79 | * The latest sequence reflected in the engine's authoritative command read |
| 69 | * model (0 if none). Used to gauge how far behind a resuming client is before | 80 | * model (0 if none). Used to gauge how far behind a resuming client is before |
apps/server/src/orchestration/Services/ProjectionPipeline.ts+10-1| ... | @@ -26,11 +26,20 @@ export interface OrchestrationProjectionPipelineShape { | ... | @@ -26,11 +26,20 @@ export interface OrchestrationProjectionPipelineShape { |
| 26 | /** | 26 | /** |
| 27 | * Project a single orchestration event into projection repositories. | 27 | * Project a single orchestration event into projection repositories. |
| 28 | * | 28 | * |
| 29 | * Projectors are executed sequentially to preserve deterministic ordering. | 29 | * Projectors run sequentially in one transaction. Attachment cleanup runs |
| 30 | * after that transaction commits. | ||
| 30 | */ | 31 | */ |
| 31 | readonly projectEvent: ( | 32 | readonly projectEvent: ( |
| 32 | event: OrchestrationEvent, | 33 | event: OrchestrationEvent, |
| 33 | ) => Effect.Effect<void, ProjectionRepositoryError>; | 34 | ) => Effect.Effect<void, ProjectionRepositoryError>; |
| 35 | |||
| 36 | /** | ||
| 37 | * Project an event inside a caller's transaction and return its attachment | ||
| 38 | * cleanup. Run the returned effect only after the outer transaction commits. | ||
| 39 | */ | ||
| 40 | readonly projectEventDeferred: ( | ||
| 41 | event: OrchestrationEvent, | ||
| 42 | ) => Effect.Effect<Effect.Effect<void>, ProjectionRepositoryError>; | ||
| 34 | } | 43 | } |
| 35 | 44 | ||
| 36 | /** | 45 | /** |
apps/server/src/orchestration/Services/ProviderCommandReactor.ts+1| ... | @@ -19,6 +19,7 @@ export interface ProviderCommandReactorShape { | ... | @@ -19,6 +19,7 @@ export interface ProviderCommandReactorShape { |
| 19 | * | 19 | * |
| 20 | * The returned effect must be run in a scope so all worker fibers can be | 20 | * The returned effect must be run in a scope so all worker fibers can be |
| 21 | * finalized on shutdown. | 21 | * finalized on shutdown. |
| 22 | * It subscribes before returning. Event handling waits for server activation. | ||
| 22 | * | 23 | * |
| 23 | * Filters orchestration domain events to provider-intent types before | 24 | * Filters orchestration domain events to provider-intent types before |
| 24 | * processing. | 25 | * processing. |
apps/server/src/relay/AgentAwarenessRelay.test.ts+2| ... | @@ -474,6 +474,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -474,6 +474,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 474 | readEvents: () => Stream.empty, | 474 | readEvents: () => Stream.empty, |
| 475 | dispatch: () => Effect.succeed({ sequence: 1 }), | 475 | dispatch: () => Effect.succeed({ sequence: 1 }), |
| 476 | streamDomainEvents: Stream.fromQueue(events), | 476 | streamDomainEvents: Stream.fromQueue(events), |
| 477 | subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)), | ||
| 477 | latestSequence: Effect.succeed(0), | 478 | latestSequence: Effect.succeed(0), |
| 478 | } satisfies OrchestrationEngineShape; | 479 | } satisfies OrchestrationEngineShape; |
| 479 | 480 | ||
| ... | @@ -666,6 +667,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -666,6 +667,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 666 | readEvents: () => Stream.empty, | 667 | readEvents: () => Stream.empty, |
| 667 | dispatch: () => Effect.succeed({ sequence: 1 }), | 668 | dispatch: () => Effect.succeed({ sequence: 1 }), |
| 668 | streamDomainEvents: Stream.fromQueue(events), | 669 | streamDomainEvents: Stream.fromQueue(events), |
| 670 | subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)), | ||
| 669 | latestSequence: Effect.succeed(0), | 671 | latestSequence: Effect.succeed(0), |
| 670 | } satisfies OrchestrationEngineShape), | 672 | } satisfies OrchestrationEngineShape), |
| 671 | Layer.succeed(ProjectionSnapshotQuery, { | 673 | Layer.succeed(ProjectionSnapshotQuery, { |
apps/server/src/serverRuntimeStartup.reconcile.test.ts+2| ... | @@ -84,6 +84,7 @@ const runReconciliation = (input: { | ... | @@ -84,6 +84,7 @@ const runReconciliation = (input: { |
| 84 | readEvents: () => Stream.empty, | 84 | readEvents: () => Stream.empty, |
| 85 | dispatch: input.dispatch, | 85 | dispatch: input.dispatch, |
| 86 | streamDomainEvents: Stream.empty, | 86 | streamDomainEvents: Stream.empty, |
| 87 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 87 | latestSequence: Effect.succeed(0), | 88 | latestSequence: Effect.succeed(0), |
| 88 | }), | 89 | }), |
| 89 | Effect.provide(NodeServices.layer), | 90 | Effect.provide(NodeServices.layer), |
| ... | @@ -289,6 +290,7 @@ it.effect("does not fail startup when the live provider session inventory cannot | ... | @@ -289,6 +290,7 @@ it.effect("does not fail startup when the live provider session inventory cannot |
| 289 | readEvents: () => Stream.empty, | 290 | readEvents: () => Stream.empty, |
| 290 | dispatch: () => Effect.die("unused"), | 291 | dispatch: () => Effect.die("unused"), |
| 291 | streamDomainEvents: Stream.empty, | 292 | streamDomainEvents: Stream.empty, |
| 293 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 292 | latestSequence: Effect.succeed(0), | 294 | latestSequence: Effect.succeed(0), |
| 293 | }), | 295 | }), |
| 294 | Effect.provide(NodeServices.layer), | 296 | Effect.provide(NodeServices.layer), |
apps/server/src/serverRuntimeStartup.test.ts+3| ... | @@ -181,6 +181,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa | ... | @@ -181,6 +181,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 181 | Effect.as({ sequence: 1 }), | 181 | Effect.as({ sequence: 1 }), |
| 182 | ), | 182 | ), |
| 183 | streamDomainEvents: Stream.empty, | 183 | streamDomainEvents: Stream.empty, |
| 184 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 184 | latestSequence: Effect.succeed(0), | 185 | latestSequence: Effect.succeed(0), |
| 185 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), | 186 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), |
| 186 | Effect.provide(NodeServices.layer), | 187 | Effect.provide(NodeServices.layer), |
| ... | @@ -227,6 +228,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when | ... | @@ -227,6 +228,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 227 | Effect.as({ sequence: 1 }), | 228 | Effect.as({ sequence: 1 }), |
| 228 | ), | 229 | ), |
| 229 | streamDomainEvents: Stream.empty, | 230 | streamDomainEvents: Stream.empty, |
| 231 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 230 | latestSequence: Effect.succeed(0), | 232 | latestSequence: Effect.succeed(0), |
| 231 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), | 233 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), |
| 232 | Effect.provide(NodeServices.layer), | 234 | Effect.provide(NodeServices.layer), |
| ... | @@ -279,6 +281,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa | ... | @@ -279,6 +281,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 279 | Effect.as({ sequence: 1 }), | 281 | Effect.as({ sequence: 1 }), |
| 280 | ), | 282 | ), |
| 281 | streamDomainEvents: Stream.empty, | 283 | streamDomainEvents: Stream.empty, |
| 284 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 282 | latestSequence: Effect.succeed(0), | 285 | latestSequence: Effect.succeed(0), |
| 283 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), | 286 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), |
| 284 | Effect.provideService(Crypto.Crypto, { | 287 | Effect.provideService(Crypto.Crypto, { |
docs/internals/overview.md+2-1| ... | @@ -73,7 +73,8 @@ processing is totally ordered. For each envelope `processEnvelope`: | ... | @@ -73,7 +73,8 @@ processing is totally ordered. For each envelope `processEnvelope`: |
| 73 | 3. inside one SQL transaction, appends events to the event store, applies them to the in-memory read | 73 | 3. inside one SQL transaction, appends events to the event store, applies them to the in-memory read |
| 74 | model via [`projector.ts`][projector], projects them into persisted tables, and writes the | 74 | model via [`projector.ts`][projector], projects them into persisted tables, and writes the |
| 75 | accepted receipt; | 75 | accepted receipt; |
| 76 | 4. after commit, swaps in the new read model and publishes committed events to subscribers. | 76 | 4. after commit, swaps in the new read model, cleans up attachments, and publishes committed events |
| 77 | to subscribers. Attachment cleanup failures are logged and do not reject committed commands. | ||
| 77 | 78 | ||
| 78 | Because persistence and projection share a transaction, the read model cannot durably disagree with | 79 | Because persistence and projection share a transaction, the read model cannot durably disagree with |
| 79 | the event log. On dispatch failure the engine rereads persisted events past the starting sequence and | 80 | the event log. On dispatch failure the engine rereads persisted events past the starting sequence and |