authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 18:25:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:25:55-07:00
log716069f40f000887cb01685099a16e19d9194307
treec7381f01cf7e62fa6a53a819e9fb02e8972d9b9f
parent0e77fbd3d0d79eec5247e75583a04590a1785133
signature Signed by PGP key B5690EEEBB952194

fix(server): keep attachments until the command commits (#7941)


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 };
11361138
...@@ -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 };
12821285
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;
228229
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 }
235237
...@@ -253,6 +255,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -253,6 +255,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
253255
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 );
268271
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 multiple391 // 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(
874it.layer(875it.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));
10691071
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 });
10851087
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 ];
17671767
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 };
17761840
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 });
18001844
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 );
18171861
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 );
18301887
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"
61import * as NodeServices from "@effect/platform-node/NodeServices";61import * as NodeServices from "@effect/platform-node/NodeServices";
62import * as Clock from "effect/Clock";62import * as Clock from "effect/Clock";
63import { ServerSettingsService } from "../../serverSettings.ts";63import { ServerSettingsService } from "../../serverSettings.ts";
64import { ServerActivation } from "../../serverActivation.ts";
64import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";65import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
65import * as GitWorkflowService from "../../git/GitWorkflowService.ts";66import * as GitWorkflowService from "../../git/GitWorkflowService.ts";
6667
...@@ -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 }
501504
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);
505515
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 });
570580
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 });
15561556
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));
15581560
1559 // The domain event stream is hot, so work pending before this reactor1561 // The domain event stream is hot, so work pending before this reactor
1560 // starts cannot be resumed. Correlated completions only clear the request1562 // 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";
18import * as Context from "effect/Context";18import * as Context from "effect/Context";
19import type * as Effect from "effect/Effect";19import type * as Effect from "effect/Effect";
20import type * as Scope from "effect/Scope";
20import type * as Stream from "effect/Stream";21import type * as Stream from "effect/Stream";
2122
22import type { OrchestrationDispatchError } from "../Errors.ts";23import 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>;
6667
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 read79 * 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 before80 * 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}
3544
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 be20 * 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 before24 * 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;
479480
...@@ -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`:
733. inside one SQL transaction, appends events to the event store, applies them to the in-memory read733. 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 the74 model via [`projector.ts`][projector], projects them into persisted tables, and writes the
75 accepted receipt;75 accepted receipt;
764. after commit, swaps in the new read model and publishes committed events to subscribers.764. 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.
7778
78Because persistence and projection share a transaction, the read model cannot durably disagree with79Because persistence and projection share a transaction, the read model cannot durably disagree with
79the event log. On dispatch failure the engine rereads persisted events past the starting sequence and80the event log. On dispatch failure the engine rereads persisted events past the starting sequence and