Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ describe("OrchestrationEngine", () => {
Layer.succeed(OrchestrationProjectionPipeline, {
bootstrap: Effect.void,
projectEvent: () => Effect.void,
projectEventDeferred: () => Effect.succeed(Effect.void),
} satisfies OrchestrationProjectionPipelineShape),
),
Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)),
Expand Down Expand Up @@ -1115,7 +1116,8 @@ describe("OrchestrationEngine", () => {
let shouldFailRequestedProjection = true;
const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = {
bootstrap: Effect.void,
projectEvent: (event) => {
projectEvent: () => Effect.void,
projectEventDeferred: (event) => {
if (
shouldFailRequestedProjection &&
event.commandId === CommandId.make("cmd-turn-start-atomic") &&
Expand All @@ -1129,7 +1131,7 @@ describe("OrchestrationEngine", () => {
}),
);
}
return Effect.void;
return Effect.succeed(Effect.void);
},
};

Expand Down Expand Up @@ -1262,7 +1264,8 @@ describe("OrchestrationEngine", () => {
let shouldFailProjection = true;
const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = {
bootstrap: Effect.void,
projectEvent: (event) => {
projectEvent: () => Effect.void,
projectEventDeferred: (event) => {
if (
shouldFailProjection &&
event.commandId === CommandId.make("cmd-thread-archive-sync-fail")
Expand All @@ -1275,7 +1278,7 @@ describe("OrchestrationEngine", () => {
}),
);
}
return Effect.void;
return Effect.succeed(Effect.void);
},
};

Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/orchestration/Layers/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,14 @@ const makeOrchestrationEngine = Effect.gen(function* () {
.withTransaction(
Effect.gen(function* () {
const committedEvents: OrchestrationEvent[] = [];
const attachmentCleanups: Effect.Effect<void>[] = [];
let nextCommandReadModel = commandReadModel;

for (const nextEvent of eventBases) {
const savedEvent = yield* eventStore.append(nextEvent);
nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent);
yield* projectionPipeline.projectEvent(savedEvent);
const cleanup = yield* projectionPipeline.projectEventDeferred(savedEvent);
attachmentCleanups.push(cleanup);
committedEvents.push(savedEvent);
}

Expand All @@ -253,6 +255,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {

return {
committedEvents,
attachmentCleanups,
lastSequence: lastSavedEvent.sequence,
nextCommandReadModel,
} as const;
Expand All @@ -267,6 +270,9 @@ const makeOrchestrationEngine = Effect.gen(function* () {
);

commandReadModel = committedCommand.nextCommandReadModel;
for (const cleanup of committedCommand.attachmentCleanups) {
yield* cleanup;
Comment thread
t3dotgg marked this conversation as resolved.
}
Comment thread
t3dotgg marked this conversation as resolved.
for (const [index, event] of committedCommand.committedEvents.entries()) {
yield* PubSub.publish(eventPubSub, event);
if (index === 0) {
Expand Down Expand Up @@ -381,6 +387,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
return {
readEvents,
dispatch,
subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)),
// Each access creates a fresh PubSub subscription so that multiple
// consumers (wsServer, ProviderRuntimeIngestion, CheckpointReactor, etc.)
// each independently receive all domain events.
Expand Down
179 changes: 177 additions & 2 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CheckpointRef,
CommandId,
CorrelationId,
DEFAULT_PROVIDER_INTERACTION_MODE,
EventId,
MessageId,
ProjectId,
Expand Down Expand Up @@ -874,12 +875,13 @@ it.layer(
it.layer(
Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")),
)("OrchestrationProjectionPipeline", (it) => {
it.effect("removes unreferenced attachment files when a thread is reverted", () =>
it.effect("prunes reverted attachments only after every projector commits", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const projectionPipeline = yield* OrchestrationProjectionPipeline;
const eventStore = yield* OrchestrationEventStore;
const sql = yield* SqlClient.SqlClient;
const { attachmentsDir } = yield* ServerConfig;
const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("Thread Revert.Files");
Expand Down Expand Up @@ -1067,7 +1069,7 @@ it.layer(
assert.isTrue(yield* exists(removePath));
assert.isTrue(yield* exists(otherThreadPath));

yield* appendAndProject({
const revertedEvent = yield* eventStore.append({
type: "thread.reverted",
eventId: EventId.make("evt-revert-files-7"),
aggregateKind: "thread",
Expand All @@ -1083,9 +1085,75 @@ it.layer(
},
});

yield* sql`
CREATE TRIGGER fail_revert_projection
BEFORE UPDATE ON projection_state
WHEN NEW.projector = 'projection.threads'
BEGIN
SELECT RAISE(FAIL, 'forced later projector failure');
END
`;
const projectionError = yield* projectionPipeline
.projectEvent(revertedEvent)
.pipe(Effect.flip);
assert.equal(projectionError._tag, "PersistenceSqlError");
assert.isTrue(yield* exists(removePath));
const rolledBackMessages = yield* sql<{ readonly messageId: string }>`
SELECT message_id AS "messageId" FROM projection_thread_messages
WHERE message_id = 'message-remove'
`;
assert.deepEqual(rolledBackMessages, [{ messageId: "message-remove" }]);
yield* sql`DROP TRIGGER fail_revert_projection`;

const laterAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000005";
const laterPath = path.join(attachmentsDir, `${laterAttachmentId}.png`);
yield* fileSystem.writeFileString(laterPath, "added after revert");
const cleanup = yield* sql.withTransaction(
Effect.gen(function* () {
const cleanup = yield* projectionPipeline.projectEventDeferred(revertedEvent);
yield* appendAndProject({
type: "thread.message-sent",
eventId: EventId.make("evt-revert-files-later"),
aggregateKind: "thread",
aggregateId: threadId,
occurredAt: now,
commandId: CommandId.make("cmd-revert-files-later"),
causationEventId: null,
correlationId: CorrelationId.make("cmd-revert-files-later"),
metadata: {},
payload: {
threadId,
messageId: MessageId.make("message-later"),
role: "user",
text: "Later attachment",
attachments: [
{
type: "image",
id: laterAttachmentId,
name: "later.png",
mimeType: "image/png",
sizeBytes: 5,
},
],
turnId: null,
streaming: false,
createdAt: now,
updatedAt: now,
},
});
assert.isTrue(yield* exists(removePath));
// Return the cleanup effect so the caller runs it after the outer transaction commits.
// @effect-diagnostics-next-line returnEffectInGen:off
return cleanup;
}),
);
assert.isTrue(yield* exists(removePath));
yield* cleanup;

assert.isTrue(yield* exists(keepPath));
assert.isTrue(yield* exists(keepFilePath));
assert.isFalse(yield* exists(removePath));
assert.isTrue(yield* exists(laterPath));
assert.isTrue(yield* exists(otherThreadPath));
}),
);
Expand Down Expand Up @@ -3320,4 +3388,111 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
assert.isNull(detail.session);
}),
);

it.effect("cleans attachments only after the command receipt commits", () =>
Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
const sql = yield* SqlClient.SqlClient;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { attachmentsDir } = yield* ServerConfig;
const createdAt = "2026-01-01T00:00:00.000Z";
const projectId = ProjectId.make("project-outer-rollback");
const threadId = ThreadId.make("thread-outer-rollback");
const cleanupFailureThreadId = ThreadId.make("thread-cleanup-failure");
const commandId = CommandId.make("cmd-outer-rollback-delete");
const attachmentPath = path.join(
attachmentsDir,
"thread-outer-rollback-00000000-0000-4000-8000-000000000001.png",
);
const blockedAttachmentPath = path.join(
attachmentsDir,
"thread-cleanup-failure-00000000-0000-4000-8000-000000000001.png",
);

yield* engine.dispatch({
type: "project.create",
commandId: CommandId.make("cmd-outer-rollback-project"),
projectId,
title: "Outer rollback project",
workspaceRoot: "/tmp/project-outer-rollback",
createdAt,
});
for (const id of [threadId, cleanupFailureThreadId]) {
yield* engine.dispatch({
type: "thread.create",
commandId: CommandId.make(`cmd-create-${id}`),
threadId: id,
projectId,
title: "Attachment cleanup thread",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt,
});
}

yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
yield* fileSystem.writeFileString(attachmentPath, "keep this attachment");
yield* sql`
CREATE TRIGGER fail_attachment_command_receipt
BEFORE INSERT ON orchestration_command_receipts
WHEN NEW.command_id = 'cmd-outer-rollback-delete' AND NEW.status = 'accepted'
BEGIN
SELECT RAISE(FAIL, 'forced receipt failure');
END
`;
const deleteCommand = { type: "thread.delete", commandId, threadId } as const;
const dispatchError = yield* engine.dispatch(deleteCommand).pipe(Effect.flip);
assert.equal(dispatchError._tag, "PersistenceSqlError");
assert.equal(yield* fileSystem.readFileString(attachmentPath), "keep this attachment");
const rolledBackThreads = yield* sql<{ readonly deletedAt: string | null }>`
SELECT deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId}
`;
assert.deepEqual(rolledBackThreads, [{ deletedAt: null }]);
const rolledBackEvents = yield* sql`
SELECT sequence FROM orchestration_events WHERE command_id = ${commandId}
`;
assert.deepEqual(rolledBackEvents, []);
const rolledBackReceipts = yield* sql`
SELECT status FROM orchestration_command_receipts WHERE command_id = ${commandId}
`;
assert.deepEqual(rolledBackReceipts, []);
yield* sql`DROP TRIGGER fail_attachment_command_receipt`;

const result = yield* engine.dispatch(deleteCommand);
assert.isFalse(yield* exists(attachmentPath));
const committedReceipts = yield* sql<{
readonly status: string;
readonly resultSequence: number;
}>`
SELECT status, result_sequence AS "resultSequence"
FROM orchestration_command_receipts WHERE command_id = ${commandId}
`;
assert.deepEqual(committedReceipts, [
{ status: "accepted", resultSequence: result.sequence },
]);

// Removing a nonempty directory as a file fails after the command commits.
yield* fileSystem.makeDirectory(blockedAttachmentPath);
yield* fileSystem.writeFileString(path.join(blockedAttachmentPath, "keep.txt"), "keep");
const cleanupFailureCommandId = CommandId.make("cmd-cleanup-failure-delete");
yield* engine.dispatch({
type: "thread.delete",
commandId: cleanupFailureCommandId,
threadId: cleanupFailureThreadId,
});
assert.isTrue(yield* exists(blockedAttachmentPath));
const cleanupFailureReceipts = yield* sql<{ readonly status: string }>`
SELECT status FROM orchestration_command_receipts
WHERE command_id = ${cleanupFailureCommandId}
`;
assert.deepEqual(cleanupFailureReceipts, [{ status: "accepted" }]);
}),
);
});
Loading
Loading