import { type ChatAttachment, type CommandId, MessageId, type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2Command, type OrchestrationV2ConversationMessage, type OrchestrationV2CreationSource, type OrchestrationV2Run, type OrchestrationV2ThreadShellSnapshot, type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, ProjectId, RunId, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { OrchestratorDispatchError, OrchestratorProjectionError, OrchestratorV2, type OrchestratorV2DispatchResult, type OrchestratorV2Error, } from "./Orchestrator.ts"; import { LegacyV1ThreadImporter, type LegacyV1ThreadImportError, } from "./LegacyV1ThreadImporter.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; export interface ThreadManagementProvenance { readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; } export function withCreationProvenance( command: OrchestrationV2Command, provenance: ThreadManagementProvenance, ): OrchestrationV2Command { switch (command.type) { case "thread.create": case "message.dispatch": case "thread.fork": case "thread.merge_back": case "delegated_task.request": return { ...command, ...provenance }; default: return command; } } export function existingThreadIdsForCommand( command: OrchestrationV2Command, ): ReadonlyArray { switch (command.type) { case "thread.create": return []; // Read-state commands only rewrite the thread payload's visited/unread // watermark; they never touch messages, so they do not need the imported // v1 transcript hydrated first. Visits fire on every thread-activity bump // while a thread is open, so keeping them off the import path matters. case "thread.visit": case "thread.mark-unread": return []; case "thread.fork": return [command.sourceThreadId]; case "thread.merge_back": return command.sourceThreadId === command.targetThreadId ? [command.sourceThreadId] : [command.sourceThreadId, command.targetThreadId]; case "delegated_task.request": case "delegated_task.wake-policy": case "delegated_task.completion-delivery.acknowledge": case "delegated_task.completion-delivery.dispose": return [command.parentThreadId]; case "thread.created.record": return command.parentThreadId === command.targetThreadId ? [command.parentThreadId] : [command.parentThreadId, command.targetThreadId]; default: return [command.threadId]; } } export type ThreadManagementTerminalRunStatus = Extract< OrchestrationV2Run["status"], "completed" | "failed" | "cancelled" | "interrupted" | "rolled_back" >; export interface ThreadManagementSendInput { readonly projectId: ProjectId; readonly commandId: CommandId; readonly threadId: ThreadId; readonly messageId: MessageId; readonly text: string; readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly mode: ThreadManagementSendMode; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; } export interface ThreadManagementSendResult { readonly dispatch: OrchestratorV2DispatchResult; readonly projection: OrchestrationV2ThreadProjection; readonly message: OrchestrationV2ConversationMessage; readonly run: OrchestrationV2Run; /** Null for queued sends: the user turn item materializes when the queued turn starts. */ readonly turnItem: Extract | null; readonly delivery: "started" | "queued" | "steered" | "restarted"; } export interface ThreadManagementWaitInput { readonly projectId: ProjectId; readonly threadId: ThreadId; readonly runId?: RunId; readonly timeoutMs: number; readonly pollIntervalMs?: number; } export interface ThreadManagementWaitResult { readonly threadId: ThreadId; readonly run: OrchestrationV2Run | null; readonly timedOut: boolean; } export interface ThreadManagementInterruptInput { readonly projectId: ProjectId; readonly commandId: CommandId; readonly threadId: ThreadId; readonly runId?: RunId; readonly reason?: string; } export type ThreadManagementInterruptResult = | { readonly type: "interrupt_requested"; readonly run: OrchestrationV2Run; readonly dispatch: OrchestratorV2DispatchResult; } | { readonly type: "no_active_run" } | { readonly type: "already_terminal"; readonly run: OrchestrationV2Run & { readonly status: ThreadManagementTerminalRunStatus }; }; export class ThreadManagementThreadNotFoundError extends Schema.TaggedErrorClass()( "ThreadManagementThreadNotFoundError", { projectId: ProjectId, threadId: ThreadId, }, ) { override get message(): string { return `Thread ${this.threadId} was not found in project ${this.projectId}.`; } } export class ThreadManagementRunNotFoundError extends Schema.TaggedErrorClass()( "ThreadManagementRunNotFoundError", { threadId: ThreadId, runId: RunId, }, ) { override get message(): string { return `Run ${this.runId} does not belong to thread ${this.threadId}.`; } } export class ThreadManagementThreadArchivedError extends Schema.TaggedErrorClass()( "ThreadManagementThreadArchivedError", { threadId: ThreadId, }, ) { override get message(): string { return `Thread ${this.threadId} is archived and cannot receive messages.`; } } export class ThreadManagementNoSteerableRunError extends Schema.TaggedErrorClass()( "ThreadManagementNoSteerableRunError", { threadId: ThreadId, mode: Schema.Literals(["steer", "restart"]), }, ) { override get message(): string { return `Thread ${this.threadId} has no running turn that can be ${this.mode === "steer" ? "steered" : "restarted"}.`; } } export class ThreadManagementThreadNotInterruptibleError extends Schema.TaggedErrorClass()( "ThreadManagementThreadNotInterruptibleError", { threadId: ThreadId, runId: RunId, }, ) { override get message(): string { return `Run ${this.runId} is not currently interruptible.`; } } export class ThreadManagementProjectionLoadError extends Schema.TaggedErrorClass()( "ThreadManagementProjectionLoadError", { projectId: ProjectId, threadId: ThreadId, cause: Schema.Defect(), }, ) { override get message(): string { return `Unable to load thread ${this.threadId} in project ${this.projectId}.`; } } export class ThreadManagementProjectThreadsListError extends Schema.TaggedErrorClass()( "ThreadManagementProjectThreadsListError", { projectId: ProjectId, cause: Schema.Defect(), }, ) { override get message(): string { return `Unable to list threads in project ${this.projectId}.`; } } export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErrorClass()( "ThreadManagementDurableRunProjectionError", { threadId: ThreadId, messageId: MessageId, }, ) { override get message(): string { return `Message ${this.messageId} was accepted on thread ${this.threadId} without a durable run projection.`; } } export const ThreadManagementError = Schema.Union([ ThreadManagementThreadNotFoundError, ThreadManagementRunNotFoundError, ThreadManagementThreadArchivedError, ThreadManagementNoSteerableRunError, ThreadManagementThreadNotInterruptibleError, ThreadManagementProjectionLoadError, ThreadManagementProjectThreadsListError, ThreadManagementDurableRunProjectionError, ]); export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | OrchestratorV2Error; export interface ThreadManagementServiceShape { readonly ensureLegacyTranscript: ( threadId: ThreadId, ) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; readonly getCommandReceipt: OrchestratorV2["Service"]["getCommandReceipt"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; readonly getCheckpointContext: OrchestratorV2["Service"]["getCheckpointContext"]; readonly getThreadSnapshot: OrchestratorV2["Service"]["getThreadSnapshot"]; readonly getThreadSnapshotWindow: OrchestratorV2["Service"]["getThreadSnapshotWindow"]; readonly getProjectThread: (input: { readonly projectId: ProjectId; readonly threadId: ThreadId; }) => Effect.Effect; readonly getShellSnapshot: (options?: { readonly location?: "active" | "archive"; }) => Effect.Effect; readonly getThreadShell: OrchestratorV2["Service"]["getThreadShell"]; readonly listProjectThreads: (input: { readonly projectId: ProjectId; readonly includeSubagents: boolean; }) => Effect.Effect, ThreadManagementError>; readonly sendToThread: ( input: ThreadManagementSendInput, ) => Effect.Effect; readonly waitForThread: ( input: ThreadManagementWaitInput, ) => Effect.Effect; readonly interruptThread: ( input: ThreadManagementInterruptInput, ) => Effect.Effect; readonly getThreadEventSequence: OrchestratorV2["Service"]["getThreadEventSequence"]; readonly streamStoredEvents: OrchestratorV2["Service"]["streamStoredEvents"]; readonly streamStoredEventsFrom: OrchestratorV2["Service"]["streamStoredEventsFrom"]; readonly streamDomainEvents: OrchestratorV2["Service"]["streamDomainEvents"]; } export class ThreadManagementService extends Context.Service< ThreadManagementService, ThreadManagementServiceShape >()("t3/orchestration-v2/ThreadManagementService") {} export function isActiveRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || run.status === "starting" || run.status === "running" || run.status === "waiting" ); } export function isTerminalRunStatus( status: OrchestrationV2Run["status"], ): status is ThreadManagementTerminalRunStatus { return ( status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted" || status === "rolled_back" ); } export function latestRun( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2Run | undefined { return projection.runs.toSorted((left, right) => right.ordinal - left.ordinal)[0]; } export function latestActiveRun( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2Run | undefined { return projection.runs .filter(isActiveRun) .toSorted((left, right) => right.ordinal - left.ordinal)[0]; } export function latestSteerableRun( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2Run | undefined { return projection.runs .filter( (run) => run.status === "running" && run.activeAttemptId !== null && projection.providerTurns.some( (turn) => turn.runAttemptId === run.activeAttemptId && turn.status === "running", ), ) .toSorted((left, right) => right.ordinal - left.ordinal)[0]; } const make = Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; const legacyImporter = yield* LegacyV1ThreadImporter; const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", )(function* (threadId: ThreadId) { yield* legacyImporter.ensureTranscript(threadId).pipe( Effect.tapError((cause) => Effect.logWarning("Unable to hydrate migrated v1 thread transcript", { threadId, cause, }), ), ); }); const ensureProjectionTranscript = (threadId: ThreadId) => ensureLegacyTranscript(threadId).pipe( Effect.mapError( (cause) => new OrchestratorProjectionError({ threadId, cause, }), ), ); const ensureCommandTranscripts = Effect.fn( "orchestrationV2.threadManagement.ensureCommandTranscripts", )(function* (command: OrchestrationV2Command) { yield* Effect.forEach( existingThreadIdsForCommand(command), (threadId) => ensureLegacyTranscript(threadId), { discard: true }, ).pipe( Effect.mapError( (cause) => new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, cause, }), ), ); }); const getThreadProjection: ThreadManagementServiceShape["getThreadProjection"] = (threadId) => ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getThreadProjection(threadId)), ); const getCheckpointContext: ThreadManagementServiceShape["getCheckpointContext"] = (threadId) => ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getCheckpointContext(threadId)), ); const getThreadSnapshot: ThreadManagementServiceShape["getThreadSnapshot"] = (threadId) => ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getThreadSnapshot(threadId)), ); const getThreadSnapshotWindow: ThreadManagementServiceShape["getThreadSnapshotWindow"] = ( threadId, options, ) => ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)), ); const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command))); const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => getThreadProjection(input.threadId).pipe( Effect.mapError( (cause) => new ThreadManagementProjectionLoadError({ projectId: input.projectId, threadId: input.threadId, cause, }), ), Effect.flatMap((projection) => projection.thread.projectId === input.projectId && projection.thread.deletedAt === null ? Effect.succeed(projection) : Effect.fail( new ThreadManagementThreadNotFoundError({ projectId: input.projectId, threadId: input.threadId, }), ), ), ); const listProjectThreads: ThreadManagementServiceShape["listProjectThreads"] = (input) => orchestrator.getShellSnapshot().pipe( Effect.mapError( (cause) => new ThreadManagementProjectThreadsListError({ projectId: input.projectId, cause, }), ), Effect.map((snapshot) => snapshot.threads .filter((thread) => thread.projectId === input.projectId) .filter( (thread) => input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", ) .toSorted( (left, right) => DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || right.id.localeCompare(left.id), ), ), ); const sendToThread: ThreadManagementServiceShape["sendToThread"] = (input) => Effect.gen(function* () { const target = yield* getProjectThread(input); if (target.thread.archivedAt !== null) { return yield* new ThreadManagementThreadArchivedError({ threadId: input.threadId, }); } const steerableRun = latestSteerableRun(target); let dispatchMode: Extract< OrchestrationV2Command, { readonly type: "message.dispatch" } >["dispatchMode"]; if (input.mode === "steer" || input.mode === "restart") { if (steerableRun === undefined) { return yield* new ThreadManagementNoSteerableRunError({ threadId: input.threadId, mode: input.mode, }); } dispatchMode = { type: input.mode === "steer" ? "steer_active" : "restart_active", targetRunId: steerableRun.id, }; } else if (input.mode === "auto" && steerableRun !== undefined) { dispatchMode = { type: "steer_active", targetRunId: steerableRun.id }; } else { dispatchMode = { type: input.mode === "queue" ? "queue_after_active" : "start_immediately", }; } const dispatch = yield* orchestrator.dispatch({ type: "message.dispatch", commandId: input.commandId, threadId: input.threadId, messageId: input.messageId, text: input.text, attachments: input.attachments, ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), dispatchMode, createdBy: input.createdBy, creationSource: input.creationSource, }); const projection = yield* getProjectThread(input); const message = projection.messages.find((candidate) => candidate.id === input.messageId); const run = message?.runId === null || message?.runId === undefined ? undefined : projection.runs.find((candidate) => candidate.id === message.runId); const turnItem = projection.turnItems.find( ( candidate, ): candidate is Extract => candidate.type === "user_message" && candidate.messageId === input.messageId, ) ?? null; // A queued message's user turn item is deliberately not emitted at // dispatch time — it materializes when the queued turn actually starts, // so it can map onto the provider turn. Every other dispatch mode still // produces its turn item transactionally with the run. if ( message === undefined || run === undefined || (turnItem === null && run.status !== "queued") ) { return yield* new ThreadManagementDurableRunProjectionError({ threadId: input.threadId, messageId: input.messageId, }); } const delivery: ThreadManagementSendResult["delivery"] = turnItem === null || turnItem.inputIntent === "queued_turn" ? "queued" : turnItem.inputIntent === "turn_start" ? "started" : input.mode === "restart" ? "restarted" : "steered"; return { dispatch, projection, message, run, turnItem, delivery }; }); const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) => Effect.gen(function* () { const target = yield* getProjectThread(input); const selectedRun = input.runId === undefined ? latestRun(target) : target.runs.find((candidate) => candidate.id === input.runId); if (input.runId !== undefined && selectedRun === undefined) { return yield* new ThreadManagementRunNotFoundError({ threadId: input.threadId, runId: input.runId, }); } if (selectedRun === undefined) { return { threadId: input.threadId, run: null, timedOut: false }; } if (isTerminalRunStatus(selectedRun.status)) { return { threadId: input.threadId, run: selectedRun, timedOut: false }; } const wait = Effect.gen(function* () { while (true) { const current = yield* getProjectThread(input); const run = current.runs.find((candidate) => candidate.id === selectedRun.id); if (run === undefined) { return yield* new ThreadManagementRunNotFoundError({ threadId: input.threadId, runId: selectedRun.id, }); } if (isTerminalRunStatus(run.status)) return run; yield* Effect.sleep(Duration.millis(Math.max(1, input.pollIntervalMs ?? 250))); } }).pipe(Effect.timeoutOption(Duration.millis(Math.max(1, input.timeoutMs)))); const waited = yield* wait; if (Option.isSome(waited)) { return { threadId: input.threadId, run: waited.value, timedOut: false }; } const current = yield* getProjectThread(input); const run = current.runs.find((candidate) => candidate.id === selectedRun.id); if (run === undefined) { return yield* new ThreadManagementRunNotFoundError({ threadId: input.threadId, runId: selectedRun.id, }); } return { threadId: input.threadId, run, timedOut: true }; }); const interruptThread: ThreadManagementServiceShape["interruptThread"] = (input) => Effect.gen(function* () { const target = yield* getProjectThread(input); const explicitRun = input.runId === undefined ? undefined : target.runs.find((candidate) => candidate.id === input.runId); if (input.runId !== undefined && explicitRun === undefined) { return yield* new ThreadManagementRunNotFoundError({ threadId: input.threadId, runId: input.runId, }); } if (explicitRun !== undefined && isTerminalRunStatus(explicitRun.status)) { return { type: "already_terminal", run: explicitRun as OrchestrationV2Run & { readonly status: ThreadManagementTerminalRunStatus; }, } as const; } const interruptibleRun = latestActiveRun(target); if (interruptibleRun === undefined) { if (input.runId === undefined) { return { type: "no_active_run" } as const; } return yield* new ThreadManagementThreadNotInterruptibleError({ threadId: input.threadId, runId: input.runId, }); } if (input.runId !== undefined && interruptibleRun.id !== input.runId) { return yield* new ThreadManagementThreadNotInterruptibleError({ threadId: input.threadId, runId: input.runId, }); } const dispatch = yield* orchestrator.dispatch({ type: "run.interrupt", commandId: input.commandId, threadId: input.threadId, runId: interruptibleRun.id, ...(input.reason === undefined ? {} : { reason: input.reason }), }); return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; }); return ThreadManagementService.of({ ensureLegacyTranscript, dispatch, getCommandReceipt: orchestrator.getCommandReceipt, getThreadProjection, getCheckpointContext, getThreadSnapshot, getThreadSnapshotWindow, getProjectThread, getShellSnapshot: orchestrator.getShellSnapshot, getThreadShell: orchestrator.getThreadShell, listProjectThreads, sendToThread, waitForThread, interruptThread, getThreadEventSequence: orchestrator.getThreadEventSequence, streamStoredEvents: orchestrator.streamStoredEvents, streamStoredEventsFrom: orchestrator.streamStoredEventsFrom, streamDomainEvents: orchestrator.streamDomainEvents, }); }); const legacyV1ThreadImporterNoopLayer = Layer.succeed( LegacyV1ThreadImporter, LegacyV1ThreadImporter.of({ pendingThreadCount: Effect.succeed(0), reconcileShells: Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), ensureTranscript: () => Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), importPendingTranscripts: Effect.succeed({ importedThreadCount: 0, importedMessageCount: 0 }), }), ); export const layer: Layer.Layer = Layer.effect( ThreadManagementService, make, ).pipe(Layer.provide(legacyV1ThreadImporterNoopLayer)); export const layerWithLegacyImporter: Layer.Layer< ThreadManagementService, never, LegacyV1ThreadImporter | OrchestratorV2 > = Layer.effect(ThreadManagementService, make);