import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type MessageId, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { toUploadChatImageAttachments } from "../lib/composerImages"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useThreadShells } from "./entities"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, removeThreadOutboxMessage, } from "./thread-outbox"; import { isQueuedThreadCreationSendable, modelSelectionsEqual, resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, } from "./thread-outbox-model"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; import { editingQueuedMessageIdsAtom, useThreadOutboxMessages, useThreadOutboxShellStatuses, } from "./use-thread-outbox"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:dispatching-message-id"), ); function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); } function findThread( threads: ReadonlyArray, message: QueuedThreadMessage, ): EnvironmentThreadShell | undefined { return threads.find( (candidate) => candidate.environmentId === message.environmentId && candidate.id === message.threadId, ); } function findCreationProject( projects: ReadonlyArray, message: QueuedThreadMessage, ): EnvironmentProject | undefined { return projects.find( (candidate) => candidate.environmentId === message.environmentId && candidate.id === message.creation?.projectId, ); } function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { return CommandId.make(`${message.commandId}:${setting}`); } export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { reportFailure: false, }); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const editingQueuedMessageIds = useAtomValue(editingQueuedMessageIdsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); const projects = useProjects(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); const retryNotBeforeRef = useRef(new Map()); const retryTimersRef = useRef(new Map>()); useEffect(() => { ensureThreadOutboxLoaded(); return () => { for (const timer of retryTimersRef.current.values()) { clearTimeout(timer); } retryTimersRef.current.clear(); }; }, []); const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => { const reportFailure = ( commandResult: AtomCommandResult, stage: ThreadOutboxCommandStage, ): boolean => { if (!AsyncResult.isFailure(commandResult)) { return false; } const action = resolveThreadOutboxFailureAction({ stage, error: Cause.squash(commandResult.cause), interrupted: Cause.hasInterruptsOnly(commandResult.cause), }); const retry = action === "retry"; console.warn("[thread-outbox] queued message delivery failed", { environmentId: queuedMessage.environmentId, threadId: queuedMessage.threadId, messageId: queuedMessage.messageId, stage, cause: commandResult.cause, retry, }); return retry; }; const completeDelivery = async ( deliveryResult: AtomCommandResult, ): Promise => { if (reportFailure(deliveryResult, "start-turn")) { return false; } try { await removeThreadOutboxMessage(queuedMessage); return true; } catch (error) { console.warn("[thread-outbox] failed to remove delivered queued message", { environmentId: queuedMessage.environmentId, threadId: queuedMessage.threadId, messageId: queuedMessage.messageId, error, }); return false; } }; return { reportFailure, completeDelivery }; }, []); const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { const settings = resolveQueuedThreadSettings(queuedMessage, thread); const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { const updateResult = await updateThreadMetadata({ environmentId: queuedMessage.environmentId, input: { commandId: settingsCommandId(queuedMessage, "model-selection"), threadId: queuedMessage.threadId, modelSelection: settings.modelSelection, }, }); if (AsyncResult.isFailure(updateResult)) { reportFailure(updateResult, "settings-sync"); return false; } } if (settings.runtimeMode !== thread.runtimeMode) { const runtimeResult = await setThreadRuntimeMode({ environmentId: queuedMessage.environmentId, input: { commandId: settingsCommandId(queuedMessage, "runtime-mode"), threadId: queuedMessage.threadId, runtimeMode: settings.runtimeMode, createdAt: queuedMessage.createdAt, }, }); if (AsyncResult.isFailure(runtimeResult)) { reportFailure(runtimeResult, "settings-sync"); return false; } } if (settings.interactionMode !== thread.interactionMode) { const interactionResult = await setThreadInteractionMode({ environmentId: queuedMessage.environmentId, input: { commandId: settingsCommandId(queuedMessage, "interaction-mode"), threadId: queuedMessage.threadId, interactionMode: settings.interactionMode, createdAt: queuedMessage.createdAt, }, }); if (AsyncResult.isFailure(interactionResult)) { reportFailure(interactionResult, "settings-sync"); return false; } } const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { commandId: queuedMessage.commandId, threadId: queuedMessage.threadId, message: { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, interactionMode: settings.interactionMode, createdAt: queuedMessage.createdAt, }, }); return completeDelivery(deliveryResult); }, [ makeDeliveryHelpers, setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata, ], ); const sendQueuedCreation = useCallback( async ( queuedMessage: QueuedThreadMessage, creation: QueuedThreadCreation, projectCwd: string, ) => { const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { return false; } const { completeDelivery } = makeDeliveryHelpers(queuedMessage); const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ projectId: creation.projectId, projectCwd, threadId: queuedMessage.threadId, commandId: queuedMessage.commandId, messageId: queuedMessage.messageId, createdAt: queuedMessage.createdAt, text: queuedMessage.text.trim(), attachments: queuedMessage.attachments, modelSelection, runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, workspaceMode: creation.workspaceMode, branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); return completeDelivery(deliveryResult); }, [makeDeliveryHelpers, startTurn], ); useEffect(() => { if (dispatchingQueuedMessageId !== null) { return; } for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { const nextQueuedMessage = queuedMessages[0]; if (!nextQueuedMessage) { continue; } if (editingQueuedMessageIds[nextQueuedMessage.messageId]) { continue; } if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { continue; } const thread = findThread(threads, nextQueuedMessage); if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { continue; } const creation = nextQueuedMessage.creation; const environment = connectedEnvironments.find( (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, ); const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty"; const deliveryAction = resolveThreadOutboxDeliveryAction({ isCreation: creation !== undefined, threadExists: thread !== undefined, shellStatus, environmentConnected: environment?.connectionState === "connected", threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", }); if (deliveryAction === "wait") { continue; } // The live project shell is preferred for the workspace path, with the // snapshot taken at enqueue time as the fallback so a task never dies // just because its project shell is not loaded. const creationProjectCwd = creation !== undefined ? (findCreationProject(projects, nextQueuedMessage)?.workspaceRoot ?? creation.projectCwd ?? null) : null; // An incomplete pending task (e.g. worktree mode without a branch) stays // queued until the user finishes it in the editor. if (deliveryAction === "send" && creation !== undefined) { if (!isQueuedThreadCreationSendable(nextQueuedMessage)) { continue; } if (creationProjectCwd === null && shellStatus !== "live") { continue; } } beginDispatchingQueuedMessage(nextQueuedMessage.messageId); const removeQueuedMessage = (warning: string) => removeThreadOutboxMessage(nextQueuedMessage).then( () => true, (error) => { console.warn(warning, { environmentId: nextQueuedMessage.environmentId, threadId: nextQueuedMessage.threadId, messageId: nextQueuedMessage.messageId, error, }); return false; }, ); // Enqueues publish optimistically before their durable write settles. // Confirm the write landed (and the message wasn't rolled back) before // sending, so a failed write can never chase an already-delivered turn. const delivery = confirmThreadOutboxMessageQueued(nextQueuedMessage).then((queued) => { if (!queued) { // Rolled back by a failed write; nothing to deliver or retry. return true; } // The guards evaluated before the confirmation await are stale by now: // the user may have opened this message in the editor. Re-read that // guard and defer to the next drain pass (returning true skips the // failure/backoff path) rather than sending a payload being edited. if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { return true; } return deliveryAction === "remove" ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") : creation !== undefined ? creationProjectCwd !== null ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project") : thread !== undefined ? sendQueuedMessage(nextQueuedMessage, thread) : Promise.resolve(false); }); void delivery .then((sent) => { if (sent) { retryAttemptRef.current.delete(nextQueuedMessage.messageId); retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); if (pendingTimer !== undefined) { clearTimeout(pendingTimer); retryTimersRef.current.delete(nextQueuedMessage.messageId); } return; } const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); if (pendingTimer !== undefined) { clearTimeout(pendingTimer); } const retryTimer = setTimeout(() => { retryTimersRef.current.delete(nextQueuedMessage.messageId); setRetryTick((current) => current + 1); }, retryDelayMs); retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); }) .finally(() => { finishDispatchingQueuedMessage(nextQueuedMessage.messageId); }); return; } }, [ connectedEnvironments, dispatchingQueuedMessageId, editingQueuedMessageIds, projects, queuedMessagesByThreadKey, retryTick, sendQueuedCreation, sendQueuedMessage, shellStatuses, threads, ]); }