| 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 | 222 | Layer.succeed(OrchestrationProjectionPipeline, { |
| 223 | 223 | bootstrap: Effect.void, |
| 224 | 224 | projectEvent: () => Effect.void, |
| 225 | projectEventDeferred: () => Effect.succeed(Effect.void), | |
| 225 | 226 | } satisfies OrchestrationProjectionPipelineShape), |
| 226 | 227 | ), |
| 227 | 228 | Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), |
| ... | ... | @@ -1116,7 +1117,8 @@ describe("OrchestrationEngine", () => { |
| 1116 | 1117 | let shouldFailRequestedProjection = true; |
| 1117 | 1118 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { |
| 1118 | 1119 | bootstrap: Effect.void, |
| 1119 | projectEvent: (event) => { | |
| 1120 | projectEvent: () => Effect.void, | |
| 1121 | projectEventDeferred: (event) => { | |
| 1120 | 1122 | if ( |
| 1121 | 1123 | shouldFailRequestedProjection && |
| 1122 | 1124 | event.commandId === CommandId.make("cmd-turn-start-atomic") && |
| ... | ... | @@ -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 | 1265 | let shouldFailProjection = true; |
| 1264 | 1266 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { |
| 1265 | 1267 | bootstrap: Effect.void, |
| 1266 | projectEvent: (event) => { | |
| 1268 | projectEvent: () => Effect.void, | |
| 1269 | projectEventDeferred: (event) => { | |
| 1267 | 1270 | if ( |
| 1268 | 1271 | shouldFailProjection && |
| 1269 | 1272 | event.commandId === CommandId.make("cmd-thread-archive-sync-fail") |
| ... | ... | @@ -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 | 224 | .withTransaction( |
| 225 | 225 | Effect.gen(function* () { |
| 226 | 226 | const committedEvents: OrchestrationEvent[] = []; |
| 227 | const attachmentCleanups: Effect.Effect<void>[] = []; | |
| 227 | 228 | let nextCommandReadModel = commandReadModel; |
| 228 | 229 | |
| 229 | 230 | for (const nextEvent of eventBases) { |
| 230 | 231 | const savedEvent = yield* eventStore.append(nextEvent); |
| 231 | 232 | nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent); |
| 232 | yield* projectionPipeline.projectEvent(savedEvent); | |
| 233 | const cleanup = yield* projectionPipeline.projectEventDeferred(savedEvent); | |
| 234 | attachmentCleanups.push(cleanup); | |
| 233 | 235 | committedEvents.push(savedEvent); |
| 234 | 236 | } |
| 235 | 237 | |
| ... | ... | @@ -253,6 +255,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 253 | 255 | |
| 254 | 256 | return { |
| 255 | 257 | committedEvents, |
| 258 | attachmentCleanups, | |
| 256 | 259 | lastSequence: lastSavedEvent.sequence, |
| 257 | 260 | nextCommandReadModel, |
| 258 | 261 | } as const; |
| ... | ... | @@ -267,6 +270,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 267 | 270 | ); |
| 268 | 271 | |
| 269 | 272 | commandReadModel = committedCommand.nextCommandReadModel; |
| 273 | for (const cleanup of committedCommand.attachmentCleanups) { | |
| 274 | yield* cleanup; | |
| 275 | } | |
| 270 | 276 | for (const [index, event] of committedCommand.committedEvents.entries()) { |
| 271 | 277 | yield* PubSub.publish(eventPubSub, event); |
| 272 | 278 | if (index === 0) { |
| ... | ... | @@ -381,6 +387,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 381 | 387 | return { |
| 382 | 388 | readEvents, |
| 383 | 389 | dispatch, |
| 390 | subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)), | |
| 384 | 391 | // Each access creates a fresh PubSub subscription so that multiple |
| 385 | 392 | // consumers (wsServer, ProviderRuntimeIngestion, CheckpointReactor, etc.) |
| 386 | 393 | // each independently receive all domain events. |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+177-2| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | CheckpointRef, |
| 3 | 3 | CommandId, |
| 4 | 4 | CorrelationId, |
| 5 | DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 5 | 6 | EventId, |
| 6 | 7 | MessageId, |
| 7 | 8 | ProjectId, |
| ... | ... | @@ -874,12 +875,13 @@ it.layer( |
| 874 | 875 | it.layer( |
| 875 | 876 | Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), |
| 876 | 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 | 879 | Effect.gen(function* () { |
| 879 | 880 | const fileSystem = yield* FileSystem.FileSystem; |
| 880 | 881 | const path = yield* Path.Path; |
| 881 | 882 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 882 | 883 | const eventStore = yield* OrchestrationEventStore; |
| 884 | const sql = yield* SqlClient.SqlClient; | |
| 883 | 885 | const { attachmentsDir } = yield* ServerConfig; |
| 884 | 886 | const now = "2026-01-01T00:00:00.000Z"; |
| 885 | 887 | const threadId = ThreadId.make("Thread Revert.Files"); |
| ... | ... | @@ -1067,7 +1069,7 @@ it.layer( |
| 1067 | 1069 | assert.isTrue(yield* exists(removePath)); |
| 1068 | 1070 | assert.isTrue(yield* exists(otherThreadPath)); |
| 1069 | 1071 | |
| 1070 | yield* appendAndProject({ | |
| 1072 | const revertedEvent = yield* eventStore.append({ | |
| 1071 | 1073 | type: "thread.reverted", |
| 1072 | 1074 | eventId: EventId.make("evt-revert-files-7"), |
| 1073 | 1075 | aggregateKind: "thread", |
| ... | ... | @@ -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 | 1153 | assert.isTrue(yield* exists(keepPath)); |
| 1087 | 1154 | assert.isTrue(yield* exists(keepFilePath)); |
| 1088 | 1155 | assert.isFalse(yield* exists(removePath)); |
| 1156 | assert.isTrue(yield* exists(laterPath)); | |
| 1089 | 1157 | assert.isTrue(yield* exists(otherThreadPath)); |
| 1090 | 1158 | }), |
| 1091 | 1159 | ); |
| ... | ... | @@ -3522,4 +3590,111 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 3522 | 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 | 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 | 1832 | const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( |
| 1769 | 1833 | projector: ProjectorDefinition, |
| 1770 | 1834 | event: OrchestrationEvent, |
| ... | ... | @@ -1774,28 +1838,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1774 | 1838 | prunedThreadRelativePaths: new Map<string, Set<string>>(), |
| 1775 | 1839 | }; |
| 1776 | 1840 | |
| 1777 | yield* sql.withTransaction( | |
| 1778 | projector.apply(event, attachmentSideEffects).pipe( | |
| 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 | ); | |
| 1841 | yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects)); | |
| 1842 | yield* applyAttachmentSideEffects(event, attachmentSideEffects); | |
| 1799 | 1843 | }); |
| 1800 | 1844 | |
| 1801 | 1845 | const bootstrapProjector = (projector: ProjectorDefinition) => |
| ... | ... | @@ -1815,19 +1859,39 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1815 | 1859 | ), |
| 1816 | 1860 | ); |
| 1817 | 1861 | |
| 1818 | const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => | |
| 1819 | Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { | |
| 1820 | concurrency: 1, | |
| 1821 | }).pipe( | |
| 1862 | const projectEventDeferred: OrchestrationProjectionPipelineShape["projectEventDeferred"] = | |
| 1863 | Effect.fn("projectEventDeferred")( | |
| 1864 | function* (event) { | |
| 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 | 1880 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 1823 | 1881 | Effect.provideService(Path.Path, path), |
| 1824 | 1882 | Effect.provideService(ServerConfig, serverConfig), |
| 1825 | Effect.asVoid, | |
| 1826 | 1883 | Effect.catchTag("SqlError", (sqlError) => |
| 1827 | 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 | 1895 | const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( |
| 1832 | 1896 | projectors, |
| 1833 | 1897 | bootstrapProjector, |
| ... | ... | @@ -1850,6 +1914,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1850 | 1914 | return { |
| 1851 | 1915 | bootstrap, |
| 1852 | 1916 | projectEvent, |
| 1917 | projectEventDeferred, | |
| 1853 | 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 | 61 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 62 | 62 | import * as Clock from "effect/Clock"; |
| 63 | 63 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 64 | import { ServerActivation } from "../../serverActivation.ts"; | |
| 64 | 65 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 65 | 66 | import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; |
| 66 | 67 | |
| ... | ... | @@ -150,6 +151,7 @@ describe("ProviderCommandReactor", () => { |
| 150 | 151 | readonly requiresNewThreadForModelChange?: boolean; |
| 151 | 152 | readonly titleRegenerationCompletionDispatchFailures?: number; |
| 152 | 153 | readonly titleRegenerationBeforeStart?: "one" | "two"; |
| 154 | readonly serverActivation?: Effect.Effect<void>; | |
| 153 | 155 | readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 154 | 156 | readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 155 | 157 | readonly startSessionEffect?: ( |
| ... | ... | @@ -395,6 +397,7 @@ describe("ProviderCommandReactor", () => { |
| 395 | 397 | get streamDomainEvents() { |
| 396 | 398 | return engine.streamDomainEvents; |
| 397 | 399 | }, |
| 400 | subscribeDomainEvents: engine.subscribeDomainEvents, | |
| 398 | 401 | latestSequence: engine.latestSequence, |
| 399 | 402 | } satisfies OrchestrationEngineService["Service"]; |
| 400 | 403 | }), |
| ... | ... | @@ -500,7 +503,14 @@ describe("ProviderCommandReactor", () => { |
| 500 | 503 | } |
| 501 | 504 | |
| 502 | 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 | 514 | const drain = () => Effect.runPromise(reactor.drain); |
| 505 | 515 | |
| 506 | 516 | return { |
| ... | ... | @@ -568,6 +578,45 @@ describe("ProviderCommandReactor", () => { |
| 568 | 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 | 620 | effectIt.effect("projects starting before a slow provider session finishes", () => |
| 572 | 621 | Effect.gen(function* () { |
| 573 | 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 | 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 | 1561 | // The domain event stream is hot, so work pending before this reactor |
| 1560 | 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 | 17 | } from "@t3tools/contracts"; |
| 18 | 18 | import * as Context from "effect/Context"; |
| 19 | 19 | import type * as Effect from "effect/Effect"; |
| 20 | import type * as Scope from "effect/Scope"; | |
| 20 | 21 | import type * as Stream from "effect/Stream"; |
| 21 | 22 | |
| 22 | 23 | import type { OrchestrationDispatchError } from "../Errors.ts"; |
| ... | ... | @@ -64,6 +65,16 @@ export interface OrchestrationEngineShape { |
| 64 | 65 | */ |
| 65 | 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 | 79 | * The latest sequence reflected in the engine's authoritative command read |
| 69 | 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 | 26 | /** |
| 27 | 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 | 32 | readonly projectEvent: ( |
| 32 | 33 | event: OrchestrationEvent, |
| 33 | 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 | 19 | * |
| 20 | 20 | * The returned effect must be run in a scope so all worker fibers can be |
| 21 | 21 | * finalized on shutdown. |
| 22 | * It subscribes before returning. Event handling waits for server activation. | |
| 22 | 23 | * |
| 23 | 24 | * Filters orchestration domain events to provider-intent types before |
| 24 | 25 | * processing. |
apps/server/src/relay/AgentAwarenessRelay.test.ts+2| ... | ... | @@ -474,6 +474,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 474 | 474 | readEvents: () => Stream.empty, |
| 475 | 475 | dispatch: () => Effect.succeed({ sequence: 1 }), |
| 476 | 476 | streamDomainEvents: Stream.fromQueue(events), |
| 477 | subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)), | |
| 477 | 478 | latestSequence: Effect.succeed(0), |
| 478 | 479 | } satisfies OrchestrationEngineShape; |
| 479 | 480 | |
| ... | ... | @@ -666,6 +667,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 666 | 667 | readEvents: () => Stream.empty, |
| 667 | 668 | dispatch: () => Effect.succeed({ sequence: 1 }), |
| 668 | 669 | streamDomainEvents: Stream.fromQueue(events), |
| 670 | subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)), | |
| 669 | 671 | latestSequence: Effect.succeed(0), |
| 670 | 672 | } satisfies OrchestrationEngineShape), |
| 671 | 673 | Layer.succeed(ProjectionSnapshotQuery, { |
apps/server/src/serverRuntimeStartup.reconcile.test.ts+2| ... | ... | @@ -84,6 +84,7 @@ const runReconciliation = (input: { |
| 84 | 84 | readEvents: () => Stream.empty, |
| 85 | 85 | dispatch: input.dispatch, |
| 86 | 86 | streamDomainEvents: Stream.empty, |
| 87 | subscribeDomainEvents: Effect.succeed(Stream.empty), | |
| 87 | 88 | latestSequence: Effect.succeed(0), |
| 88 | 89 | }), |
| 89 | 90 | Effect.provide(NodeServices.layer), |
| ... | ... | @@ -289,6 +290,7 @@ it.effect("does not fail startup when the live provider session inventory cannot |
| 289 | 290 | readEvents: () => Stream.empty, |
| 290 | 291 | dispatch: () => Effect.die("unused"), |
| 291 | 292 | streamDomainEvents: Stream.empty, |
| 293 | subscribeDomainEvents: Effect.succeed(Stream.empty), | |
| 292 | 294 | latestSequence: Effect.succeed(0), |
| 293 | 295 | }), |
| 294 | 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 | 181 | Effect.as({ sequence: 1 }), |
| 182 | 182 | ), |
| 183 | 183 | streamDomainEvents: Stream.empty, |
| 184 | subscribeDomainEvents: Effect.succeed(Stream.empty), | |
| 184 | 185 | latestSequence: Effect.succeed(0), |
| 185 | 186 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), |
| 186 | 187 | Effect.provide(NodeServices.layer), |
| ... | ... | @@ -227,6 +228,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 227 | 228 | Effect.as({ sequence: 1 }), |
| 228 | 229 | ), |
| 229 | 230 | streamDomainEvents: Stream.empty, |
| 231 | subscribeDomainEvents: Effect.succeed(Stream.empty), | |
| 230 | 232 | latestSequence: Effect.succeed(0), |
| 231 | 233 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), |
| 232 | 234 | Effect.provide(NodeServices.layer), |
| ... | ... | @@ -279,6 +281,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 279 | 281 | Effect.as({ sequence: 1 }), |
| 280 | 282 | ), |
| 281 | 283 | streamDomainEvents: Stream.empty, |
| 284 | subscribeDomainEvents: Effect.succeed(Stream.empty), | |
| 282 | 285 | latestSequence: Effect.succeed(0), |
| 283 | 286 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), |
| 284 | 287 | Effect.provideService(Crypto.Crypto, { |
docs/internals/overview.md+2-1| ... | ... | @@ -73,7 +73,8 @@ processing is totally ordered. For each envelope `processEnvelope`: |
| 73 | 73 | 3. inside one SQL transaction, appends events to the event store, applies them to the in-memory read |
| 74 | 74 | model via [`projector.ts`][projector], projects them into persisted tables, and writes the |
| 75 | 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 | 79 | Because persistence and projection share a transaction, the read model cannot durably disagree with |
| 79 | 80 | the event log. On dispatch failure the engine rereads persisted events past the starting sequence and |