425 lines · 15.8 KB · TypeScript
RawDownload
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<MessageId | null>(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<EnvironmentThreadShell>,  message: QueuedThreadMessage,): EnvironmentThreadShell | undefined {  return threads.find(    (candidate) =>      candidate.environmentId === message.environmentId && candidate.id === message.threadId,  );} function findCreationProject(  projects: ReadonlyArray<EnvironmentProject>,  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<MessageId, number>());  const retryNotBeforeRef = useRef(new Map<MessageId, number>());  const retryTimersRef = useRef(new Map<MessageId, ReturnType<typeof setTimeout>>());   useEffect(() => {    ensureThreadOutboxLoaded();    return () => {      for (const timer of retryTimersRef.current.values()) {        clearTimeout(timer);      }      retryTimersRef.current.clear();    };  }, []);   const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => {    const reportFailure = (      commandResult: AtomCommandResult<unknown, unknown>,      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<unknown, unknown>,    ): Promise<boolean> => {      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,  ]);}