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
38 changes: 20 additions & 18 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5674,9 +5674,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
}),
),
);
// Only side chat forks lock the source; validate the modes copied here, not an earlier read.
// Read under the source's lock; validate the modes copied here, not an earlier read.
yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread);
if (command.sideChat === true && sourceProjection.thread.deletedAt !== null) {
if (sourceProjection.thread.deletedAt !== null) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
Expand Down Expand Up @@ -15646,28 +15646,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
commandThreadId(command),
dispatchWithReceiptEffect(command),
);
// A parent's lock comes before its side chats' locks, so a delete cascade and a side chat
// fork or command never interleave. A fork from a side chat or onto an existing thread would
// invert that order; dispatch rejects both, so they skip the source lock.
if (
command.type === "thread.fork" &&
command.sideChat === true &&
command.sourceThreadId !== command.targetThreadId
) {
// A fork holds its source's lock, so a source deletion and its fork never interleave. A
// parent's lock comes before its side chats' locks. A side chat fork from a side chat or a
// fork onto an existing thread would invert that order; dispatch rejects both, so they skip it.
if (command.type === "thread.fork" && command.sourceThreadId !== command.targetThreadId) {
const source = yield* findThread(command.sourceThreadId);
const target = yield* findThread(command.targetThreadId);
return yield* source === null || source.lineage.sideChat === true || target !== null
const skipsSourceLock =
source === null ||
target !== null ||
(command.sideChat === true && source.lineage.sideChat === true);
return yield* skipsSourceLock
? dispatch
: threadDispatch.withLock(command.sourceThreadId, dispatch);
}
if (command.type === "thread.delete")
return yield* threadDispatch.withLock(
command.threadId,
liveSideChatIds(command.threadId).pipe(
Effect.flatMap((sideChatIds) =>
sideChatIds.reduce(
(locked, sideChatId) => threadDispatch.withLock(sideChatId, locked),
dispatchWithReceiptEffect(command, sideChatIds),
return yield* threadDispatch.withDeletionLock(
threadDispatch.withLock(
command.threadId,
liveSideChatIds(command.threadId).pipe(
Effect.flatMap((sideChatIds) =>
sideChatIds.reduce(
(locked, sideChatId) => threadDispatch.withLock(sideChatId, locked),
dispatchWithReceiptEffect(command, sideChatIds),
),
),
),
),
Expand Down
194 changes: 136 additions & 58 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6288,45 +6288,62 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
Effect.mapError(controlReadError(threadId)),
);

// A live run fork shows its source's history through the fork run without copying it.
const shownByLiveFork = (threadId: ThreadId, runIdColumn: string) => sql`
EXISTS (
SELECT 1
FROM orchestration_v2_projection_threads AS fork
JOIN orchestration_v2_projection_runs AS fork_run
ON fork_run.run_id = json_extract(fork.payload_json, '$.forkedFrom.runId')
LEFT JOIN orchestration_v2_projection_runs AS item_run
ON item_run.run_id = ${sql.literal(runIdColumn)}
WHERE fork_run.thread_id = ${threadId}
AND fork.deleted_at IS NULL
AND json_extract(fork.payload_json, '$.forkedFrom.type') = 'run'
AND json_extract(fork.payload_json, '$.forkedFrom.threadId') = ${threadId}
AND json_extract(fork.payload_json, '$.lineage.sideChat') IS NOT 1
AND (${sql.literal(runIdColumn)} IS NULL OR item_run.ordinal <= fork_run.ordinal)
)
// A run fork shows its source's history through the fork run without copying it, and every
// run fork below it shows that history too. Returns the last source run live forks show, with
// and without `leavingForkId`.
const lastRunsShownByLiveForks = (threadId: ThreadId, leavingForkId: ThreadId) =>
sql<{ with_fork: number | null; without_fork: number | null }>`
WITH RECURSIVE showing(thread_id, deleted_at, through) AS (
SELECT fork.thread_id, fork.deleted_at, fork_run.ordinal
FROM orchestration_v2_projection_context_transfers AS transfer
JOIN orchestration_v2_projection_threads AS fork
ON fork.thread_id = transfer.target_thread_id
JOIN orchestration_v2_projection_runs AS fork_run
ON fork_run.run_id = json_extract(fork.payload_json, '$.forkedFrom.runId')
WHERE transfer.source_thread_id = ${threadId}
AND transfer.type = 'fork'
AND fork_run.thread_id = ${threadId}
AND json_extract(fork.payload_json, '$.forkedFrom.type') = 'run'
AND json_extract(fork.payload_json, '$.forkedFrom.threadId') = ${threadId}
AND json_extract(fork.payload_json, '$.lineage.sideChat') IS NOT 1
UNION
SELECT child.thread_id, child.deleted_at, showing.through
FROM showing
JOIN orchestration_v2_projection_context_transfers AS transfer
ON transfer.source_thread_id = showing.thread_id AND transfer.type = 'fork'
JOIN orchestration_v2_projection_threads AS child
ON child.thread_id = transfer.target_thread_id
WHERE json_extract(child.payload_json, '$.forkedFrom.type') = 'run'
AND json_extract(child.payload_json, '$.forkedFrom.threadId') = showing.thread_id
AND json_extract(child.payload_json, '$.lineage.sideChat') IS NOT 1
)
SELECT
MAX(through) AS with_fork,
MAX(CASE WHEN thread_id <> ${leavingForkId} THEN through END) AS without_fork
FROM showing
WHERE deleted_at IS NULL
`.pipe(Effect.map((rows) => rows[0] ?? { with_fork: null, without_fork: null }));

// A row with no run predates every run (-1), so any live fork shows it.
const runOrdinalBetween = (runIdColumn: string, from: number, through: number) => sql`
COALESCE((
SELECT run.ordinal FROM orchestration_v2_projection_runs AS run
WHERE run.run_id = ${sql.literal(runIdColumn)}
), -1) BETWEEN ${from} AND ${through}
`;

// Thread deletion keeps what a live fork still shows; a reference check must count it.
const exceptShownByLiveFork = (
const runAttachmentIds = Effect.fn("ProjectionStore.runAttachmentIds")(function* (
threadId: ThreadId,
runIdColumn: string,
options: { readonly includeShownByLiveFork?: boolean } | undefined,
) =>
options?.includeShownByLiveFork === true
? sql``
: sql`AND NOT ${shownByLiveFork(threadId, runIdColumn)}`;

const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (
threadId,
options,
) =>
Effect.all([
from: number,
through: number,
) {
const [messages, renders, answerFiles] = yield* Effect.all([
sql<{ id: string }>`
SELECT DISTINCT json_extract(attachment.value, '$.id') AS id
FROM orchestration_v2_projection_messages AS message,
json_each(message.payload_json, '$.attachments') AS attachment
WHERE message.thread_id = ${threadId}
${exceptShownByLiveFork(threadId, "message.run_id", options)}
AND ${runOrdinalBetween("message.run_id", from, through)}
`,
sql<{ payload_json: string }>`
SELECT item.payload_json
Expand All @@ -6336,7 +6353,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
AND (item.payload_json LIKE '%htmlRender%'
OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}
OR item.payload_json LIKE '%preview_recording_stop%')
${exceptShownByLiveFork(threadId, "item.run_id", options)}
AND ${runOrdinalBetween("item.run_id", from, through)}
`,
sql<{ id: string }>`
SELECT DISTINCT json_extract(attachment.value, '$.id') AS id
Expand All @@ -6345,37 +6362,98 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
json_each(question.value) AS attachment
WHERE item.thread_id = ${threadId}
AND item.type = 'user_input_request'
${exceptShownByLiveFork(threadId, "item.run_id", options)}
AND ${runOrdinalBetween("item.run_id", from, through)}
`,
]);
return [
...messages.map((row) => row.id),
...answerFiles.map((row) => row.id),
...threadHtmlRenderAttachmentIds(
threadId,
renders.map((row) => {
const item = parseEncodedPayload(row.payload_json);
return {
toolName: typeof item.toolName === "string" ? item.toolName : null,
output: item.output,
};
}),
),
];
});

const unshownRunAttachmentIds = Effect.fn("ProjectionStore.unshownRunAttachmentIds")(function* (
threadId: ThreadId,
from: number,
through: number,
) {
const candidates = yield* runAttachmentIds(threadId, from, through);
if (from <= -1 || candidates.length === 0) return candidates;
const retained = new Set(yield* runAttachmentIds(threadId, -1, from - 1));
return candidates.filter((id) => !retained.has(id));
});

// Deleting a fork also frees what its deleted sources kept only for it.
const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = Effect.fn(
"ProjectionStore.getThreadAttachmentIds",
)(
function* (threadId, options) {
const deletedSources = yield* sql<{ thread_id: ThreadId }>`
WITH RECURSIVE source(thread_id) AS (
SELECT json_extract(thread.payload_json, '$.forkedFrom.threadId')
FROM orchestration_v2_projection_threads AS thread
WHERE thread.thread_id = ${threadId}
AND json_extract(thread.payload_json, '$.forkedFrom.type') = 'run'
AND json_extract(thread.payload_json, '$.lineage.sideChat') IS NOT 1
UNION
SELECT json_extract(thread.payload_json, '$.forkedFrom.threadId')
FROM orchestration_v2_projection_threads AS thread
JOIN source USING (thread_id)
WHERE thread.deleted_at IS NOT NULL
AND json_extract(thread.payload_json, '$.forkedFrom.type') = 'run'
AND json_extract(thread.payload_json, '$.lineage.sideChat') IS NOT 1
)
SELECT thread.thread_id
FROM source
JOIN orchestration_v2_projection_threads AS thread USING (thread_id)
WHERE thread.deleted_at IS NOT NULL
`;
const afterRun = (ordinal: number | null) => (ordinal === null ? -1 : ordinal + 1);
const own = yield* lastRunsShownByLiveForks(threadId, threadId);
const released = yield* Effect.forEach(
options?.includeShownByLiveFork ? [] : deletedSources,
({ thread_id: source }) =>
lastRunsShownByLiveForks(source, threadId).pipe(
Effect.flatMap((shown) =>
shown.with_fork === null
? Effect.succeed([])
: unshownRunAttachmentIds(source, afterRun(shown.without_fork), shown.with_fork),
),
),
);
// A guest's waiting files are pending uploads that no message row holds yet.
sql<{ id: string }>`
SELECT DISTINCT json_extract(attachment.value, '$.id') AS id
FROM orchestration_v2_projection_threads AS thread,
json_each(thread.payload_json, '$.guestShare.messages') AS message,
json_each(message.value, '$.attachments') AS attachment
WHERE thread.thread_id = ${threadId}
AND json_extract(message.value, '$.status') = 'pending'
`,
]).pipe(
Effect.map(([messages, renders, answerFiles, guestFiles]) => [
const guestFiles = yield* sql<{ id: string }>`
SELECT DISTINCT json_extract(attachment.value, '$.id') AS id
FROM orchestration_v2_projection_threads AS thread,
json_each(thread.payload_json, '$.guestShare.messages') AS message,
json_each(message.value, '$.attachments') AS attachment
WHERE thread.thread_id = ${threadId}
AND json_extract(message.value, '$.status') = 'pending'
`;
return [
...new Set([
...messages.map((row) => row.id),
...guestFiles.map((row) => row.id),
...answerFiles.map((row) => row.id),
...threadHtmlRenderAttachmentIds(
...(yield* unshownRunAttachmentIds(
threadId,
renders.map((row) => {
const item = parseEncodedPayload(row.payload_json);
return {
toolName: typeof item.toolName === "string" ? item.toolName : null,
output: item.output,
};
}),
),
options?.includeShownByLiveFork ? -1 : afterRun(own.with_fork),
Number.MAX_SAFE_INTEGER,
)),
...guestFiles.map((row) => row.id),
...released.flat(),
]),
]),
Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })),
);
];
},
(effect, threadId) =>
effect.pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause }))),
);

const getMessagesById = Effect.fn("ProjectionStore.getMessagesById")(
function* (threadId: ThreadId, messageIds: ReadonlyArray<MessageId>) {
Expand Down
24 changes: 22 additions & 2 deletions apps/server/src/orchestration-v2/ThreadCommandExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import type { ThreadId } from "@t3tools/contracts";
import * as KeyedLock from "@t3tools/shared/KeyedLock";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Semaphore from "effect/Semaphore";

export class ThreadCommandExecutor extends Context.Service<
ThreadCommandExecutor,
KeyedLock.KeyedLock<ThreadId>
KeyedLock.KeyedLock<ThreadId> & {
/**
* One thread deletion plans and commits at a time: each decides what its deleted fork
* sources keep for the forks still alive. Take it before any thread lock.
*/
readonly withDeletionLock: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
}
>()("t3/orchestration-v2/ThreadCommandExecutor") {}

export const layer = Layer.effect(ThreadCommandExecutor, KeyedLock.make<ThreadId>());
export const make = Effect.gen(function* () {
const deletion = yield* Semaphore.make(1);
return ThreadCommandExecutor.of({
...(yield* KeyedLock.make<ThreadId>()),
withDeletionLock: Effect.fn("ThreadCommandExecutor.withDeletionLock")(function* <A, E, R>(
effect: Effect.Effect<A, E, R>,
) {
return yield* deletion.withPermit(effect);
}),
});
});

export const layer = Layer.effect(ThreadCommandExecutor, make);
Loading
Loading