diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a5f61232..f3fbe1974 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-24.04, windows-2025] + os: [ubuntu-24.04] steps: - name: Checkout uses: actions/checkout@v6 diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e9b621c6f..90bdefe25 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; +import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -38,6 +39,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { ComposerDictationCancelAction, @@ -158,6 +160,15 @@ export function NewTaskDraftScreen(props: { connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; + const providerAdmissionReason = getProviderAdmissionUnavailableReason({ + provider: flow.selectedProviderStatus, + instanceId: flow.selectedModel ? String(flow.selectedModel.instanceId) : undefined, + providerSnapshotKnown: selectedEnvironmentServerConfig != null, + }); + const providerUnavailable = + providerAdmissionReason === null + ? null + : { headline: "Unavailable" as const, detail: providerAdmissionReason }; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); @@ -794,18 +805,20 @@ export function NewTaskDraftScreen(props: { if (voiceInput.blocksSubmission) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; - if (!selectedProject || !draftKey) { + if (!selectedProject || !draftKey || providerUnavailable) { return; } const draft = getComposerDraftSnapshot(draftKey); - // Snapshot read keeps just-typed selector state; the availability gate - // still applies so a stored selection on a disabled provider falls back - // to the flow's resolved model. + // Snapshot read keeps just-typed selector state. Ambient stale defaults + // may fall back, but a human/recovered exact provider choice must remain + // blocked rather than silently switch accounts. const modelSelection = - resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - draft.modelSelection ?? null, - ) ?? flow.selectedModel; + draft.providerSelectionExplicit === true && draft.modelSelection !== undefined + ? resolveSelectableModelSelection(selectedEnvironmentServerConfig, draft.modelSelection) + : (resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + draft.modelSelection ?? null, + ) ?? flow.selectedModel); const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; const selectedWorktreePath = @@ -837,19 +850,23 @@ export function NewTaskDraftScreen(props: { } const editingPendingTask = flow.editingPendingTask; + const retryTurnMetadata = + editingPendingTask?.deliveryHold === undefined ? null : makeTurnCommandMetadata(); if (!environmentConnected) { // Offline: park the task in the outbox; the drain sends it when the - // environment reconnects. Editing an existing pending task re-queues it - // under its original identifiers. - const metadata = editingPendingTask - ? { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - } - : makeTurnCommandMetadata(); + // environment reconnects. Ordinary edits preserve their identifiers; + // explicitly submitting a held retarget uses the fresh metadata above. + const metadata = + retryTurnMetadata ?? + (editingPendingTask + ? { + threadId: editingPendingTask.threadId, + commandId: editingPendingTask.commandId, + messageId: editingPendingTask.messageId, + createdAt: editingPendingTask.createdAt, + } + : makeTurnCommandMetadata()); const message = flow.buildPendingTaskMessage(metadata); if (!message) { return; @@ -857,6 +874,19 @@ export function NewTaskDraftScreen(props: { flow.setSubmitting(true); try { await enqueueThreadOutboxMessage(message); + if ( + editingPendingTask !== null && + editingPendingTask.deliveryHold !== undefined && + editingPendingTask.messageId !== message.messageId + ) { + try { + await removeThreadOutboxMessage(editingPendingTask); + } catch (error) { + // The replacement is already durable and the old entry remains + // held, so neither copy can lose or double-send the content. + console.warn("[new-task] failed to remove retargeted held task", error); + } + } } catch (error) { Alert.alert( "Could not queue task", @@ -912,7 +942,7 @@ export function NewTaskDraftScreen(props: { }, ...(editingPendingTask ? { - turnMetadata: { + turnMetadata: retryTurnMetadata ?? { threadId: editingPendingTask.threadId, commandId: editingPendingTask.commandId, messageId: editingPendingTask.messageId, @@ -972,8 +1002,9 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const canStart = - Boolean(flow.selectedProject) && + Boolean(flow.selectedProject?.workspaceRoot?.trim()) && Boolean(flow.selectedModel) && + providerUnavailable === null && flow.prompt.trim().length > 0 && isIncomingShareReady && !isImportingShare && @@ -1140,6 +1171,11 @@ export function NewTaskDraftScreen(props: { ) : null} {workspaceControls} + + + {title} + {detail} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.logic.ts b/apps/mobile/src/features/threads/ThreadComposer.logic.ts new file mode 100644 index 000000000..ed726c895 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadComposer.logic.ts @@ -0,0 +1,80 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; +import type { + ModelSelection, + OrchestrationSession, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; + +/** Resolve every composer surface against the persisted session binding first. */ +export function resolveThreadComposerAuthority(input: { + readonly serverConfig: Pick | null | undefined; + readonly modelSelection: ModelSelection; + readonly sessionProviderInstanceId?: ModelSelection["instanceId"] | undefined; +}): { + readonly modelSelection: ModelSelection | null; + readonly provider: ServerProvider | null; + readonly providerAdmissionAvailable: boolean; + readonly providerAdmissionReason: string | null; + readonly providerBindingMismatch: boolean; +} { + const providers = input.serverConfig?.providers ?? []; + const instanceId = input.modelSelection.instanceId; + const selectedProvider = + providers.find((candidate) => candidate.instanceId === instanceId) ?? null; + const transition = input.sessionProviderInstanceId + ? resolveProviderContinuationTransition({ + providers, + currentInstanceId: input.sessionProviderInstanceId, + targetInstanceId: instanceId, + }) + : ({ compatible: true } as const); + const providerBindingMismatch = !transition.compatible; + const provider = providerBindingMismatch + ? (providers.find((candidate) => candidate.instanceId === input.sessionProviderInstanceId) ?? + null) + : selectedProvider; + const providerAdmissionReason = transition.compatible + ? getProviderAdmissionUnavailableReason({ + provider, + instanceId: String(instanceId), + providerSnapshotKnown: input.serverConfig !== null && input.serverConfig !== undefined, + }) + : transition.reason; + return { + modelSelection: providerBindingMismatch ? null : input.modelSelection, + provider, + providerAdmissionAvailable: providerAdmissionReason === null, + providerAdmissionReason, + providerBindingMismatch, + }; +} + +/** Describe why a turn cannot be admitted immediately, even when it can be saved to the outbox. */ +export function resolveThreadComposerAdmissionReason(input: { + readonly providerReason: string | null; + readonly projectCwd: string | null; + readonly connectionState: EnvironmentConnectionPhase; +}): string | null { + if (input.providerReason !== null) return input.providerReason; + if (input.projectCwd === null) return "This thread's project workspace is unavailable."; + if (input.connectionState !== "connected") { + if (input.connectionState === "connecting" || input.connectionState === "reconnecting") { + return "The environment is still connecting. This send will remain queued."; + } + if (input.connectionState === "error") { + return "The environment connection failed. This send will remain queued."; + } + return "The environment is offline. This send will remain queued."; + } + return null; +} + +/** Provider unavailability must never remove the active turn's escape hatch. */ +export function threadComposerShowsStopAction( + status: OrchestrationSession["status"] | null | undefined, +): boolean { + return status === "running" || status === "starting"; +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.test.ts b/apps/mobile/src/features/threads/ThreadComposer.test.ts new file mode 100644 index 000000000..68aca5e5a --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadComposer.test.ts @@ -0,0 +1,189 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveThreadComposerAdmissionReason, + resolveThreadComposerAuthority, + threadComposerShowsStopAction, +} from "./ThreadComposer.logic"; + +const PRIME_REASON = + "Prime Agent requires WSL2 on native Windows. Connect to a supported environment."; + +function provider(input: { + readonly instanceId: string; + readonly driver: string; + readonly availability?: "available" | "unavailable"; + readonly unavailableReason?: string; + readonly status?: ServerProvider["status"]; + readonly continuationGroupKey?: string; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver), + enabled: input.availability !== "unavailable", + installed: true, + version: null, + status: input.status ?? (input.availability === "unavailable" ? "disabled" : "ready"), + ...(input.availability ? { availability: input.availability } : {}), + ...(input.unavailableReason ? { unavailableReason: input.unavailableReason } : {}), + ...(input.continuationGroupKey + ? { continuation: { groupKey: input.continuationGroupKey } } + : {}), + auth: { status: "authenticated" }, + checkedAt: "2026-08-06T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +describe("ThreadComposer provider authority", () => { + it("shows and blocks the unavailable Prime binding instead of a local Codex overlay", () => { + const prime = provider({ + instanceId: "primeAgent", + driver: "primeAgent", + availability: "unavailable", + unavailableReason: PRIME_REASON, + }); + const codex = provider({ instanceId: "codex", driver: "codex" }); + + const authority = resolveThreadComposerAuthority({ + serverConfig: { providers: [prime, codex] }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + }); + + expect(authority.modelSelection).toBeNull(); + expect(authority.providerBindingMismatch).toBe(true); + expect(authority.provider).toBe(prime); + expect(authority.provider?.unavailableReason).toBe(PRIME_REASON); + expect(authority.providerAdmissionAvailable).toBe(false); + }); + + it("keeps cold offline snapshots queueable and warning snapshots admissible", () => { + const selection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + expect( + resolveThreadComposerAuthority({ serverConfig: undefined, modelSelection: selection }), + ).toMatchObject({ + modelSelection: selection, + providerAdmissionAvailable: true, + providerAdmissionReason: null, + }); + expect( + resolveThreadComposerAuthority({ + serverConfig: { + providers: [provider({ instanceId: "codex", driver: "codex", status: "warning" })], + }, + modelSelection: selection, + }), + ).toMatchObject({ providerAdmissionAvailable: true, providerAdmissionReason: null }); + }); + + it("returns concrete admission reasons for every provider materialization failure", () => { + const selection = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }; + expect( + resolveThreadComposerAuthority({ serverConfig: { providers: [] }, modelSelection: selection }) + .providerAdmissionReason, + ).toContain("not configured"); + + const disabled = { + ...provider({ instanceId: "primeAgent", driver: "primeAgent" }), + enabled: false, + }; + expect( + resolveThreadComposerAuthority({ + serverConfig: { providers: [disabled] }, + modelSelection: selection, + }).providerAdmissionReason, + ).toContain("disabled"); + + const uninstalled = { ...disabled, enabled: true, installed: false }; + expect( + resolveThreadComposerAuthority({ + serverConfig: { providers: [uninstalled] }, + modelSelection: selection, + }).providerAdmissionReason, + ).toContain("not installed"); + + const unauthenticated = { + ...uninstalled, + installed: true, + auth: { status: "unauthenticated" as const }, + }; + expect( + resolveThreadComposerAuthority({ + serverConfig: { providers: [unauthenticated] }, + modelSelection: selection, + }).providerAdmissionReason, + ).toContain("Sign in"); + }); + + it("reports project and connection admission reasons without hiding offline queueing", () => { + expect( + resolveThreadComposerAdmissionReason({ + providerReason: null, + projectCwd: null, + connectionState: "connected", + }), + ).toContain("project workspace"); + expect( + resolveThreadComposerAdmissionReason({ + providerReason: null, + projectCwd: "/repo", + connectionState: "offline", + }), + ).toContain("offline"); + expect( + resolveThreadComposerAdmissionReason({ + providerReason: null, + projectCwd: "/repo", + connectionState: "connecting", + }), + ).toContain("connecting"); + }); + + it("keeps an intact model selection owned by a compatible account", () => { + const work = provider({ + instanceId: "codex", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + const personal = provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + + const authority = resolveThreadComposerAuthority({ + serverConfig: { providers: [work, personal] }, + modelSelection, + sessionProviderInstanceId: ProviderInstanceId.make("codex"), + }); + + expect(authority.modelSelection).toBe(modelSelection); + expect(authority.provider).toBe(personal); + expect(authority.providerBindingMismatch).toBe(false); + expect(authority.providerAdmissionAvailable).toBe(true); + }); + + it("keeps Stop available for an active turn when provider admission is unavailable", () => { + expect(threadComposerShowsStopAction("running")).toBe(true); + expect(threadComposerShowsStopAction("starting")).toBe(true); + expect(threadComposerShowsStopAction("ready")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d3cc019fb..0404ebcfb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,4 +1,5 @@ import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { formatSessionGoalStatus, type SessionGoalSnapshot, @@ -124,6 +125,12 @@ import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice"; +import { + resolveThreadComposerAdmissionReason, + resolveThreadComposerAuthority, + threadComposerShowsStopAction, +} from "./ThreadComposer.logic"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { ComposerDictationCancelAction, @@ -201,6 +208,7 @@ export interface ThreadComposerProps { readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly localOutboxCount: number; + readonly onManagePendingSends: () => void; readonly contextWindow: ContextWindowSnapshot | null; readonly sessionResources: SessionResourcesSnapshot | null; readonly sessionAgentDepth: SessionAgentDepthSnapshot | null; @@ -488,10 +496,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // Opening and presentation count as active so the composer stays expanded // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; - const canSend = - hasContent && - props.sessionCompactionPendingAction !== "compact" && - !isSessionCompactionInProgress(props.sessionCompaction); // Notify the parent from the derived value, not focus events: the parent // sizes the feed inset from this, and blur-during-sheet would otherwise @@ -525,14 +529,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer setIsFocused(false); onEditorFocusChange?.(false); }, [onEditorFocusChange]); + const composerAuthority = resolveThreadComposerAuthority({ + serverConfig: props.serverConfig, + modelSelection: props.selectedThread.modelSelection, + sessionProviderInstanceId: props.selectedThread.session?.providerInstanceId, + }); // #8843: an empty composer shows the interrupt button while the agent works; - // adding text or an attachment swaps it for send. + // adding text or an attachment swaps it for send. Provider admission never + // removes that active turn escape hatch. const showStopAction = - !hasContent && - (props.selectedThread.session?.status === "running" || - props.selectedThread.session?.status === "starting"); - - const currentModelSelection = props.selectedThread.modelSelection; + !hasContent && threadComposerShowsStopAction(props.selectedThread.session?.status); + // A mismatched persisted selection is presentation-only. Admission remains + // blocked until the user selects an exact model for the bound instance. + const currentModelSelection = + composerAuthority.modelSelection ?? props.selectedThread.modelSelection; const currentRuntimeMode = resolveModelSelectionRuntimeMode( props.serverConfig, currentModelSelection, @@ -548,14 +558,26 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentLabel: props.environmentLabel, threadSyncPhase: props.threadSyncPhase, }); - const selectedProviderStatus = useMemo(() => { - if (!props.serverConfig) return null; - return ( - props.serverConfig.providers.find( - (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, - ) ?? null - ); - }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + const selectedProviderStatus = composerAuthority.provider; + const providerAdmissionReason = composerAuthority.providerAdmissionReason; + const projectAdmissionReason = + props.projectCwd === null ? "This thread's project workspace is unavailable." : null; + const blockingAdmissionReason = providerAdmissionReason ?? projectAdmissionReason; + const composerAdmissionReason = resolveThreadComposerAdmissionReason({ + providerReason: providerAdmissionReason, + projectCwd: props.projectCwd, + connectionState: props.connectionState, + }); + const selectedProviderUnavailable = + blockingAdmissionReason === null + ? null + : { headline: "Unavailable" as const, detail: blockingAdmissionReason }; + const canSend = + hasContent && + composerAuthority.providerAdmissionAvailable && + props.projectCwd !== null && + props.sessionCompactionPendingAction !== "compact" && + !isSessionCompactionInProgress(props.sessionCompaction); const activeSessionProviderStatus = useMemo(() => { const instanceId = props.selectedThread.session?.providerInstanceId; if (!props.serverConfig || instanceId === undefined) return null; @@ -569,6 +591,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer activeSessionProviderStatus?.requiresNewThreadForModelChange === true); const getModelChangeDisabledReason = useCallback( (option: ModelOption) => { + const boundInstanceId = props.selectedThread.session?.providerInstanceId; + if (boundInstanceId) { + const transition = resolveProviderContinuationTransition({ + providers: props.serverConfig?.providers ?? [], + currentInstanceId: boundInstanceId, + targetInstanceId: option.selection.instanceId, + }); + if (!transition.compatible) return transition.reason; + } const isCurrent = option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model; @@ -587,7 +618,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "Start a new thread to use this model" : undefined; }, - [currentModelSelection, modelChangesLocked, props.selectedThread.session], + [currentModelSelection, modelChangesLocked, props.selectedThread.session, props.serverConfig], ); const quickQuestionAvailable = canOpenQuickQuestion({ connectionState: props.connectionState, @@ -754,6 +785,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer supportsSessionInputQueue(activeSessionProviderStatus); const canQueueFollowUp = props.connectionState === "connected" && + composerAuthority.providerAdmissionAvailable && props.selectedThread.session?.status === "running" && !props.sessionInputBlocked && props.localOutboxCount === 0 && @@ -781,17 +813,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const canSetSessionInputQueueModes = showSessionInputQueueModes && props.connectionState === "connected" && + composerAuthority.providerAdmissionAvailable && (props.selectedThread.session?.status === "ready" || props.selectedThread.session?.status === "running") && !isMutatingSessionInputQueue; // A busy thread is no longer a reason to hold a message back: the outbox now // delivers while a turn runs so the message steers it. Only a lost connection // or an already-queued message still means "saved rather than sent". - const sendLabel = canQueueFollowUp - ? "Queue follow-up" - : props.connectionState !== "connected" || props.localOutboxCount > 0 - ? "Save pending send" - : "Send"; + const sendLabel = selectedProviderUnavailable + ? `Send unavailable. ${selectedProviderUnavailable.detail}` + : canQueueFollowUp + ? "Queue follow-up" + : props.connectionState !== "connected" + ? `Save pending send. ${composerAdmissionReason ?? "The environment is disconnected."}` + : props.localOutboxCount > 0 + ? "Save pending send" + : "Send"; const showSessionResourceReload = props.selectedThread.session?.runtimeMode === "full-access" && @@ -1138,6 +1175,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "running"); const canCompactSessionContext = sessionCompactionConnected && + composerAuthority.providerAdmissionAvailable && props.sessionCompactionPendingAction === null && canStartSessionCompaction(activeSessionProviderStatus, props.sessionCompaction); const canAbortSessionContext = @@ -1146,6 +1184,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer canAbortSessionCompaction(activeSessionProviderStatus, props.sessionCompaction); const canSetSessionAutoCompaction = sessionCompactionConnected && + composerAuthority.providerAdmissionAvailable && props.sessionCompactionPendingAction === null && canConfigureSessionAutoCompaction(activeSessionProviderStatus, props.sessionCompaction); const sessionCompactionControlRef = useRef({ @@ -1446,12 +1485,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); - // An existing thread is bound to its harness: sessions can't move between - // provider instances, so the picker only offers the thread's own group. - const threadProviderGroups = useMemo( - () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), - [providerGroups, currentModelSelection.instanceId], - ); + // Keep every configured group visible. `getModelChangeDisabledReason` + // enables exact continuation peers and explains why every other account + // needs a new thread. + const threadProviderGroups = providerGroups; const currentModelOption = modelOptions.find( (option) => @@ -1575,6 +1612,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : null} + + 0 ? ( - - {props.localOutboxCount} pending send{props.localOutboxCount === 1 ? "" : "s"} on this - device. - + + + {props.localOutboxCount} pending send{props.localOutboxCount === 1 ? "" : "s"} on + this device · Manage + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index c18358a76..7314392de 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -149,6 +149,7 @@ export interface ThreadDetailScreenProps { readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; readonly localOutboxCount: number; + readonly onManagePendingSends: () => void; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; @@ -931,6 +932,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThread={props.selectedThread} serverConfig={props.serverConfig} localOutboxCount={props.localOutboxCount} + onManagePendingSends={props.onManagePendingSends} contextWindow={props.contextWindow} sessionResources={props.sessionResources} sessionAgentDepth={props.sessionAgentDepth} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fd8a46419..70de63b38 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -897,6 +897,7 @@ function ThreadRouteContent( projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} localOutboxCount={composer.selectedThreadQueueCount} + onManagePendingSends={composer.onManagePendingSends} layoutVariant={layout.variant} usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenConnectionEditor={handleOpenConnectionEditor} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 9a8bb0501..2e2fe32cf 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getProviderUnavailablePresentation } from "@t3tools/client-runtime/providerAvailability"; import type { EnvironmentId, ModelSelection, @@ -36,6 +37,7 @@ import { resolveDefaultableModelSelection, resolveModelSelectionRuntimeMode, resolveNewTaskModelSelection, + resolveNewTaskUnavailableProvider, resolveSelectableModelSelection, showModelSelectionInteractionModeToggle, } from "../../lib/modelOptions"; @@ -66,6 +68,7 @@ import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; import { flattenQueuedThreadMessages, + preserveQueuedThreadDeliveryHold, threadOutboxManager, type QueuedThreadMessage, } from "../../state/thread-outbox"; @@ -421,19 +424,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; // Stored selections only count while their provider is usable on the - // server; otherwise the server's default model wins instead of silently - // targeting a disabled provider. The draft selection is an explicit pick - // and passes through as-is; the project default (last used, possibly from - // desktop) is implicit and additionally never resolves to a legacy model. - const draftModelSelection = resolveSelectableModelSelection( + // server. Ordinary disabled or removed choices keep the existing default + // fallback. An explicitly unavailable choice is different: preserve its + // server status for remediation and require a new pick instead of silently + // switching providers. Project and sticky defaults also reject legacy models. + const storedDraftModelSelection = selectedProjectDraft.modelSelection ?? null; + const storedProjectDefaultModelSelection = selectedProject?.defaultModelSelection ?? null; + const storedStickyModelSelection = useStickyComposerModelSelection(); + const unavailablePreferredProvider = resolveNewTaskUnavailableProvider( + selectedEnvironmentServerConfig, + { + draftSelection: storedDraftModelSelection, + projectDefaultSelection: storedProjectDefaultModelSelection, + stickySelection: storedStickyModelSelection, + }, + ); + const selectableDraftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, - selectedProjectDraft.modelSelection ?? null, + storedDraftModelSelection, ); + const draftModelSelection = + selectedProjectDraft.providerSelectionExplicit === true && storedDraftModelSelection !== null + ? storedDraftModelSelection + : selectableDraftModelSelection; const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + storedProjectDefaultModelSelection, ); - const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, storedStickyModelSelection, @@ -459,6 +476,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { projectDefaultSelection: projectDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, + unavailablePreferredProvider, }); const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` @@ -484,13 +502,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderStatus = useMemo( - () => - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === selectedModel?.instanceId, - ) ?? null, - [selectedEnvironmentServerConfig, selectedModel?.instanceId], - ); + const selectedProviderStatus = useMemo(() => { + if (unavailablePreferredProvider) return unavailablePreferredProvider; + const selected = selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + ); + if (selected) return selected; + if (modelOptions.length > 0) return null; + return ( + selectedEnvironmentServerConfig?.providers.find((provider) => + getProviderUnavailablePresentation(provider), + ) ?? null + ); + }, [ + modelOptions.length, + selectedEnvironmentServerConfig, + selectedModel?.instanceId, + unavailablePreferredProvider, + ]); const setSelectedModelKey = useCallback( // Options ride along in the same write: a follow-up setSelectedModelOptions // call would rebuild the selection from the stale pre-switch model. @@ -505,6 +534,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const modelSelection = options ? { ...option.selection, options } : option.selection; updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection, + providerSelectionExplicit: true, runtimeMode: resolveModelSelectionRuntimeMode( selectedEnvironmentServerConfig, modelSelection, @@ -534,6 +564,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }; updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: nextSelection, + providerSelectionExplicit: true, }); setStickyComposerModelSelection(nextSelection); }, @@ -861,6 +892,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { replaceComposerDraftAttachments(draftKey, message.attachments); updateComposerDraftSettings(draftKey, { modelSelection: message.modelSelection, + providerSelectionExplicit: message.modelSelection !== undefined, runtimeMode: message.runtimeMode, interactionMode: message.interactionMode, workspaceSelection: { @@ -914,6 +946,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projectCwd = usingPendingSnapshot ? editingPendingTask?.creation?.projectCwd : selectedProject.workspaceRoot; + const preservedDeliveryHold = preserveQueuedThreadDeliveryHold(editingPendingTask, metadata); return { environmentId: selectedProject.environmentId, threadId: ThreadId.make(metadata.threadId), @@ -927,6 +960,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { draftModelSelection, draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, ), + ...(preservedDeliveryHold === undefined ? {} : { deliveryHold: preservedDeliveryHold }), interactionMode: planModeEnabled && showModelSelectionInteractionModeToggle( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 76b7a4a75..8e5e06ed1 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -258,6 +258,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: /* ─── Pending task row ───────────────────────────────────────────────── */ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "retarget", title: "Retarget", image: "arrow.triangle.2.circlepath" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -288,14 +289,17 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "retarget") onSelectPendingTask(pendingTask); if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, - [onDeletePendingTask, pendingTask], + [onDeletePendingTask, onSelectPendingTask, pendingTask], ); const statusPill = ( - Pending + + {pendingTask.message.deliveryHold ? "Held" : "Pending"} + ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 84b0eee7b..633a4b141 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -189,6 +189,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS }); const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "retarget", title: "Retarget", image: "arrow.triangle.2.circlepath" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -222,9 +223,10 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "retarget") onSelectPendingTask(pendingTask); if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, - [onDeletePendingTask, pendingTask], + [onDeletePendingTask, onSelectPendingTask, pendingTask], ); const rowContent = ( @@ -242,7 +244,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {projectTitle} - Queued + + {pendingTask.message.deliveryHold ? "Held" : "Queued"} + {/* One line, unlike the two an active row allows: a queued title is derived from the whole prompt rather than written as a title, so the diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 49d0a0e61..2f1d1f5d9 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -4,12 +4,15 @@ import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3t import { buildModelOptions, + canSendToModelSelection, groupByProvider, getModelSelectionSupportedRuntimeModes, + getModelSelectionUnavailablePresentation, resolveDefaultableModelSelection, resolveModelSelectionRuntimeMode, showModelSelectionInteractionModeToggle, resolveNewTaskModelSelection, + resolveNewTaskUnavailableProvider, resolveSelectableModelSelection, type ModelOption, } from "./modelOptions"; @@ -244,6 +247,132 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + it("blocks existing-thread sends and gives the unavailable reason precedence", () => { + const reason = + "Prime Agent is unavailable because this Pylon server is running on native Windows. Run the Pylon server and Prime Agent in WSL2, or connect this client to a Pylon server running in WSL2 or another remote environment."; + const config = { + providers: [ + { + instanceId: "primeAgent", + driver: "primeAgent", + displayName: "Prime Agent", + enabled: true, + installed: true, + availability: "unavailable", + unavailableReason: reason, + message: "Disabled", + auth: { status: "authenticated" }, + models: [ + { + slug: "default", + name: "Prime Agent Default", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + const selection = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }; + + expect(resolveSelectableModelSelection(config, selection)).toBeNull(); + expect(canSendToModelSelection(config, selection)).toBe(false); + expect(canSendToModelSelection(null, selection)).toBe(true); + expect(getModelSelectionUnavailablePresentation(config, selection)).toEqual({ + headline: "Unavailable", + detail: reason, + }); + expect(buildModelOptions(config, selection)).toEqual([]); + }); + + it("holds an unavailable stored choice instead of silently falling back for a new task", () => { + const unavailable = { + instanceId: "primeAgent", + driver: "primeAgent", + displayName: "Prime Agent", + enabled: false, + installed: false, + availability: "unavailable", + unavailableReason: "Run Prime Agent in WSL2.", + auth: { status: "unknown" }, + models: [], + }; + const config = { + providers: [ + unavailable, + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-default", + name: "GPT Default", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + const storedPrime = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }; + const options = buildModelOptions(config, null); + + expect(options).toHaveLength(1); + expect(resolveSelectableModelSelection(config, storedPrime)).toBeNull(); + const unavailablePreferredProvider = resolveNewTaskUnavailableProvider(config, { + draftSelection: null, + projectDefaultSelection: storedPrime, + stickySelection: null, + }); + expect(unavailablePreferredProvider).toMatchObject(unavailable); + expect( + resolveNewTaskModelSelection({ + draftSelection: null, + projectDefaultSelection: null, + stickySelection: null, + modelOptions: options, + unavailablePreferredProvider, + }), + ).toBeNull(); + }); + + it("keeps an authoritative disabled stored choice blocked for remediation", () => { + const disabled = { + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: false, + installed: true, + auth: { status: "authenticated" }, + models: [], + }; + const config = { providers: [disabled] } as unknown as ServerConfig; + const selection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "sonnet", + }; + + expect( + resolveNewTaskUnavailableProvider(config, { + draftSelection: selection, + projectDefaultSelection: null, + stickySelection: null, + }), + ).toBe(disabled); + expect(getModelSelectionUnavailablePresentation(config, selection)).toBeNull(); + }); + it("keeps legacy models out of implicit defaults", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 9ad58ae56..e43f36e0e 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,8 +1,14 @@ +import { + getProviderAdmissionAvailability, + getProviderUnavailablePresentation, + type ProviderUnavailablePresentation, +} from "@t3tools/client-runtime/providerAvailability"; import type { ModelCapabilities, ModelSelection, RuntimeMode, ServerConfig as T3ServerConfig, + ServerProvider, } from "@t3tools/contracts"; import { getServerProviderSupportedRuntimeModes, @@ -69,9 +75,10 @@ function normalizeSelectionOptions( /** * A stored model selection is only usable when its provider instance is - * currently enabled, installed, and authenticated on the server. Returns the - * selection unchanged when usable, otherwise `null` so callers fall through to - * the server's default model. A missing config (environment offline) cannot be + * currently enabled, installed, authenticated, and available on the server. + * Returns the selection unchanged when usable, otherwise `null`. Callers can + * either fall through or hold an unavailable choice for explicit remediation. + * A missing config (environment offline) cannot be * validated, so stored selections pass through untouched. */ export function resolveSelectableModelSelection( @@ -84,10 +91,11 @@ export function resolveSelectableModelSelection( const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); - return provider && - provider.enabled && - provider.installed && - provider.auth.status !== "unauthenticated" + return getProviderAdmissionAvailability({ + provider, + instanceId: String(selection.instanceId), + providerSnapshotKnown: true, + }).status === "available" ? selection : null; } @@ -121,6 +129,67 @@ export function getModelSelectionProvider( ); } +export function getModelSelectionUnavailablePresentation( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null | undefined, +): ProviderUnavailablePresentation | null { + return getProviderUnavailablePresentation(getModelSelectionProvider(config, selection)); +} + +/** Keep unavailable provider shadows from reaching turn submission locally. */ +export function canSendToModelSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null | undefined, +): boolean { + if (!selection) return false; + return ( + getProviderAdmissionAvailability({ + provider: getModelSelectionProvider(config, selection), + instanceId: String(selection.instanceId), + providerSnapshotKnown: config !== null && config !== undefined, + }).status !== "unavailable" + ); +} + +/** + * Preserve the highest-priority stored choice when its provider is explicitly + * unavailable. The new-task screen can then show the server's remediation and + * require a new explicit choice instead of silently switching providers. + */ +export function resolveNewTaskUnavailableProvider( + config: T3ServerConfig | null | undefined, + input: { + readonly draftSelection: ModelSelection | null; + readonly projectDefaultSelection: ModelSelection | null; + readonly stickySelection: ModelSelection | null; + }, +): ServerProvider | null { + const candidates = [ + { selection: input.draftSelection, defaultable: false }, + { selection: input.projectDefaultSelection, defaultable: true }, + { selection: input.stickySelection, defaultable: true }, + ] as const; + for (const candidate of candidates) { + if (!candidate.selection) continue; + const provider = getModelSelectionProvider(config, candidate.selection); + if ( + provider && + getProviderAdmissionAvailability({ + provider, + instanceId: String(candidate.selection.instanceId), + providerSnapshotKnown: true, + }).status === "unavailable" + ) { + return provider; + } + const usable = candidate.defaultable + ? resolveDefaultableModelSelection(config, candidate.selection) + : resolveSelectableModelSelection(config, candidate.selection); + if (usable) return null; + } + return null; +} + export function getModelSelectionSupportedRuntimeModes( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, @@ -151,7 +220,18 @@ export function resolveNewTaskModelSelection(input: { readonly projectDefaultSelection: ModelSelection | null; readonly stickySelection: ModelSelection | null; readonly modelOptions: ReadonlyArray; + readonly unavailablePreferredProvider?: ServerProvider | null; }): ModelSelection | null { + if ( + input.unavailablePreferredProvider && + getProviderAdmissionAvailability({ + provider: input.unavailablePreferredProvider, + instanceId: String(input.unavailablePreferredProvider.instanceId), + providerSnapshotKnown: true, + }).status === "unavailable" + ) { + return null; + } return ( input.draftSelection ?? input.projectDefaultSelection ?? @@ -169,7 +249,13 @@ export function buildModelOptions( const options = new Map(); for (const provider of config?.providers ?? []) { - if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") { + if ( + getProviderAdmissionAvailability({ + provider, + instanceId: String(provider.instanceId), + providerSnapshotKnown: true, + }).status !== "available" + ) { continue; } @@ -211,23 +297,25 @@ export function buildModelOptions( const provider = config?.providers.find( (candidate) => candidate.instanceId === fallbackModelSelection.instanceId, ); - const providerLabel = provider - ? providerDisplayLabel(provider) - : fallbackModelSelection.instanceId; - options.set(key, { - key, - label: fallbackModelSelection.model, - subtitle: "", - providerKey: fallbackModelSelection.instanceId, - providerLabel, - providerDriver: provider?.driver ?? fallbackModelSelection.instanceId, - supportedRuntimeModes: getServerProviderSupportedRuntimeModes(provider), - requiresNewThreadForModelChange: provider?.requiresNewThreadForModelChange === true, - isDefault: false, - isLegacy: false, - capabilities: null, - selection: fallbackModelSelection, - }); + if (getProviderUnavailablePresentation(provider) === null) { + const providerLabel = provider + ? providerDisplayLabel(provider) + : fallbackModelSelection.instanceId; + options.set(key, { + key, + label: fallbackModelSelection.model, + subtitle: "", + providerKey: fallbackModelSelection.instanceId, + providerLabel, + providerDriver: provider?.driver ?? fallbackModelSelection.instanceId, + supportedRuntimeModes: getServerProviderSupportedRuntimeModes(provider), + requiresNewThreadForModelChange: provider?.requiresNewThreadForModelChange === true, + isDefault: false, + isLegacy: false, + capabilities: null, + selection: fallbackModelSelection, + }); + } } } diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index 1bd2fbd8e..28a5de9aa 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -201,6 +201,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { return true; }); + /** + * Compare-and-set rewrite for a caller holding an exact queue snapshot. + * The revision fence also catches a replacement published while this + * mutation waits for earlier durable writes. + */ + const updateIfCurrent = ( + expected: QueuedThreadMessage, + replacement: QueuedThreadMessage, + ): Promise => { + if (!currentMessages().some((candidate) => candidate === expected)) { + return Promise.resolve(false); + } + return update(replacement, revisions.get(expected.messageId) ?? 0); + }; + // `expectedRevision` makes the removal a compare-and-set too: an edit // accepted after the caller decided to remove (restore-to-composer reads // the payload it is about to delete) keeps the newer message queued. @@ -267,6 +282,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { return removed; }); + /** Remove only the exact queue snapshot the caller inspected. */ + const removeIfCurrent = (expected: QueuedThreadMessage): Promise => { + if (!currentMessages().some((candidate) => candidate === expected)) { + return Promise.resolve(false); + } + const expectedRevision = revisions.get(expected.messageId) ?? 0; + return remove(expected, expectedRevision).then((removed) => removed !== null); + }; + const clearEnvironment = ( environmentId: EnvironmentId, ): Promise> => { @@ -386,7 +410,9 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { /** Current write revision for a queued message; input to update's CAS. */ revisionOf: (messageId: MessageId): number => revisions.get(messageId) ?? 0, update, + updateIfCurrent, remove, + removeIfCurrent, clearEnvironment, }; } diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index ed1d289ce..999dae1fa 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,6 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { getProviderAdmissionAvailability } from "@t3tools/client-runtime/providerAvailability"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { clampFileAttachmentUploadBytes, fileAttachmentTooLargeMessage, @@ -15,9 +17,11 @@ import { RuntimeMode, ThreadId, type ModelSelection as ModelSelectionType, + type OrchestrationSessionStatus, type ProjectId as ProjectIdType, type ProviderInteractionMode as ProviderInteractionModeType, type RuntimeMode as RuntimeModeType, + type ServerProvider, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -25,7 +29,7 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 3; +const THREAD_OUTBOX_SCHEMA_VERSION = 6; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; const QueuedThreadCreationSchema = Schema.Struct({ @@ -40,8 +44,22 @@ const QueuedThreadCreationSchema = Schema.Struct({ startFromOrigin: Schema.optional(Schema.Boolean), }); +const ThreadOutboxDeliveryHoldSchema = Schema.Struct({ + kind: Schema.Literals([ + "provider-unavailable", + "provider-binding-mismatch", + "provider-binding-unresolved", + "project-workspace-unavailable", + "thread-missing", + "admission-rejected", + ]), + reason: Schema.String, + boundInstanceId: Schema.optional(Schema.String), + queuedInstanceId: Schema.optional(Schema.String), +}); + export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]), + schemaVersion: Schema.Literals([1, 2, 3, 4, 5, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, @@ -51,9 +69,13 @@ export const QueuedThreadMessageSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), + deliveryHold: Schema.optional(ThreadOutboxDeliveryHoldSchema), // Present when the queued item creates a brand-new thread (pending task) // instead of appending a turn to an existing one. creation: Schema.optional(QueuedThreadCreationSchema), + // Existing sends retain enough provider-neutral destination metadata to be + // explicitly retargeted if another device deletes the thread before send. + destination: Schema.optional(QueuedThreadCreationSchema), createdAt: IsoDateTime, }); @@ -70,6 +92,19 @@ export interface QueuedThreadCreation { readonly startFromOrigin?: boolean; } +export interface ThreadOutboxDeliveryHold { + readonly kind: + | "provider-unavailable" + | "provider-binding-mismatch" + | "provider-binding-unresolved" + | "project-workspace-unavailable" + | "thread-missing" + | "admission-rejected"; + readonly reason: string; + readonly boundInstanceId?: string; + readonly queuedInstanceId?: string; +} + export interface QueuedThreadMessage { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -80,7 +115,9 @@ export interface QueuedThreadMessage { readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; + readonly deliveryHold?: ThreadOutboxDeliveryHold; readonly creation?: QueuedThreadCreation; + readonly destination?: QueuedThreadCreation; readonly createdAt: string; } @@ -88,6 +125,9 @@ export interface ThreadSettingsSnapshot { readonly modelSelection: ModelSelectionType; readonly runtimeMode: RuntimeModeType; readonly interactionMode: ProviderInteractionModeType; + readonly session?: { + readonly providerInstanceId?: ModelSelectionType["instanceId"] | undefined; + } | null; } export function resolveQueuedThreadSettings( @@ -101,6 +141,202 @@ export function resolveQueuedThreadSettings( }; } +export type ThreadOutboxAdmission = + | { readonly action: "send"; readonly settings: ThreadSettingsSnapshot } + | { readonly action: "wait" } + | { readonly action: "hold"; readonly hold: ThreadOutboxDeliveryHold }; + +/** Revalidate the exact live binding before any queued command is dispatched. */ +export function resolveQueuedThreadAdmission(input: { + readonly message: QueuedThreadMessage; + readonly thread: ThreadSettingsSnapshot; + readonly providers: ReadonlyArray | null | undefined; +}): ThreadOutboxAdmission { + const providers = input.providers ?? []; + const boundInstanceId = input.thread.session?.providerInstanceId; + const queuedInstanceId = input.message.modelSelection?.instanceId; + const queuedTransition = + boundInstanceId !== undefined && + queuedInstanceId !== undefined && + queuedInstanceId !== boundInstanceId + ? resolveProviderContinuationTransition({ + providers, + currentInstanceId: boundInstanceId, + targetInstanceId: queuedInstanceId, + }) + : null; + if (queuedTransition?.compatible === false) { + return { + action: "hold", + hold: { + kind: "provider-binding-mismatch", + reason: `${queuedTransition.reason} Reconcile this pending send or delete it.`, + boundInstanceId, + ...(queuedInstanceId === undefined ? {} : { queuedInstanceId }), + }, + }; + } + + const modelSelection = + boundInstanceId === undefined + ? (input.message.modelSelection ?? input.thread.modelSelection) + : input.message.modelSelection !== undefined && + (input.message.modelSelection.instanceId === boundInstanceId || + queuedTransition?.compatible === true) + ? input.message.modelSelection + : input.thread.modelSelection.instanceId === boundInstanceId + ? input.thread.modelSelection + : null; + if (modelSelection === null) { + return { + action: "hold", + hold: { + kind: "provider-binding-unresolved", + reason: `This thread is bound to '${boundInstanceId}', but no model selection exists for that exact provider. Select a bound model or delete the pending send.`, + ...(boundInstanceId === undefined ? {} : { boundInstanceId }), + }, + }; + } + + const provider = providers.find( + (candidate) => candidate.instanceId === modelSelection.instanceId, + ); + const providerAvailability = getProviderAdmissionAvailability({ + provider, + instanceId: String(modelSelection.instanceId), + providerSnapshotKnown: input.providers !== null && input.providers !== undefined, + }); + if (providerAvailability.status === "unknown") { + return { action: "wait" }; + } + if (providerAvailability.status === "unavailable") { + return { + action: "hold", + hold: { + kind: "provider-unavailable", + reason: providerAvailability.reason, + ...(boundInstanceId === undefined ? {} : { boundInstanceId }), + queuedInstanceId: modelSelection.instanceId, + }, + }; + } + + return { + action: "send", + settings: { + modelSelection, + runtimeMode: input.message.runtimeMode ?? input.thread.runtimeMode, + interactionMode: input.message.interactionMode ?? input.thread.interactionMode, + session: input.thread.session, + }, + }; +} + +export function resolveQueuedCreationAdmission(input: { + readonly message: QueuedThreadMessage; + readonly providers: ReadonlyArray | null | undefined; +}): Exclude | { readonly action: "send" } { + const modelSelection = input.message.modelSelection; + if (modelSelection === undefined) return { action: "wait" }; + const provider = input.providers?.find( + (candidate) => candidate.instanceId === modelSelection.instanceId, + ); + const availability = getProviderAdmissionAvailability({ + provider, + instanceId: String(modelSelection.instanceId), + providerSnapshotKnown: input.providers !== null && input.providers !== undefined, + }); + if (availability.status === "unknown") return { action: "wait" }; + if (availability.status === "unavailable") { + return { + action: "hold", + hold: { + kind: "provider-unavailable", + reason: availability.reason, + queuedInstanceId: modelSelection.instanceId, + }, + }; + } + return { action: "send" }; +} + +export function preserveQueuedThreadDeliveryHold( + message: QueuedThreadMessage | null | undefined, + identity: { + readonly threadId: string; + readonly commandId: string; + readonly messageId: string; + readonly createdAt: string; + }, +): ThreadOutboxDeliveryHold | undefined { + return message !== null && + message !== undefined && + identity.threadId === message.threadId && + identity.commandId === message.commandId && + identity.messageId === message.messageId && + identity.createdAt === message.createdAt + ? message.deliveryHold + : undefined; +} + +/** + * A held existing-thread send may be explicitly retargeted only to a selected + * provider that is currently admissible and proves the thread binding's exact + * continuation identity. The returned selection is never rewritten. + */ +export function resolveHeldSendSelectedProvider(input: { + readonly boundInstanceId: ModelSelectionType["instanceId"] | undefined; + readonly selectedModelSelection: ModelSelectionType | null | undefined; + readonly providers: ReadonlyArray | null | undefined; +}): ModelSelectionType | null { + const selection = input.selectedModelSelection; + if (input.boundInstanceId === undefined || selection == null) return null; + const transition = resolveProviderContinuationTransition({ + providers: input.providers ?? [], + currentInstanceId: input.boundInstanceId, + targetInstanceId: selection.instanceId, + }); + if (!transition.compatible) return null; + const provider = input.providers?.find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + return getProviderAdmissionAvailability({ + provider, + instanceId: String(selection.instanceId), + providerSnapshotKnown: input.providers !== null && input.providers !== undefined, + }).status === "available" + ? selection + : null; +} + +export function retryQueuedThreadMessage( + message: QueuedThreadMessage, + input: { + readonly commandId: CommandId; + readonly createdAt: string; + readonly modelSelection?: ModelSelectionType; + readonly runtimeMode?: RuntimeModeType; + readonly interactionMode?: ProviderInteractionModeType; + }, +): QueuedThreadMessage { + const { deliveryHold: _hold, ...retry } = message; + return { + ...retry, + commandId: input.commandId, + createdAt: input.createdAt, + ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), + ...(input.runtimeMode === undefined ? {} : { runtimeMode: input.runtimeMode }), + ...(input.interactionMode === undefined ? {} : { interactionMode: input.interactionMode }), + }; +} + +export function threadOutboxDeliveryHoldsEqual( + left: ThreadOutboxDeliveryHold | undefined, + right: ThreadOutboxDeliveryHold | undefined, +): boolean { + return JSON.stringify(left ?? null) === JSON.stringify(right ?? null); +} + export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { return ( left.instanceId === right.instanceId && @@ -150,15 +386,31 @@ export function threadOutboxRetryDelayMs(attempt: number): number { return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS); } -export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; +export type ThreadOutboxDeliveryAction = "confirm" | "wait" | "remove" | "send"; + +export function queuedCreationWorkspaceHold(input: { + readonly message: QueuedThreadMessage; + readonly project: { readonly workspaceRoot: string | null | undefined } | null | undefined; + readonly shellStatus: EnvironmentShellStatus; +}): ThreadOutboxDeliveryHold | null { + if (input.message.creation === undefined || input.shellStatus !== "live") return null; + if (input.project?.workspaceRoot?.trim()) return null; + return { + kind: "project-workspace-unavailable", + reason: + "The queued task's project workspace is missing. Retarget it to a valid project or delete it.", + }; +} export function resolveThreadOutboxDeliveryAction(input: { readonly isCreation: boolean; readonly threadExists: boolean; readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; - readonly threadBusy: boolean; + readonly threadStatus: OrchestrationSessionStatus | null; + readonly hasDeliveryHold?: boolean; }): ThreadOutboxDeliveryAction { + if (input.hasDeliveryHold === true) return "wait"; if (input.isCreation) { // A pending task creates its thread on delivery. If the thread already // exists the creation command went through and only cleanup remains. @@ -171,12 +423,107 @@ export function resolveThreadOutboxDeliveryAction(input: { return input.environmentConnected && input.shellStatus === "live" ? "send" : "wait"; } if (!input.threadExists) { - return input.shellStatus === "live" ? "remove" : "wait"; + // A synchronized missing thread still crosses durable confirmation before + // it is converted to a provider-neutral hold. Nothing removes it here. + return input.shellStatus === "live" ? "confirm" : "wait"; + } + if (!input.environmentConnected || input.threadStatus === "starting") { + return "wait"; } - return input.environmentConnected ? "send" : "wait"; + return "send"; +} + +export type ConfirmedThreadOutboxPlan = + | { readonly action: "wait" } + | { readonly action: "remove" } + | { + readonly action: "hold"; + readonly hold: ThreadOutboxDeliveryHold; + readonly creation?: QueuedThreadCreation; + } + | { readonly action: "send-existing"; readonly settings: ThreadSettingsSnapshot } + | { readonly action: "send-creation"; readonly projectCwd: string }; + +/** Recompute every delivery authority after the durable-confirmation boundary. */ +export function resolveConfirmedThreadOutboxPlan(input: { + readonly message: QueuedThreadMessage; + readonly thread: + | (ThreadSettingsSnapshot & { + readonly session?: + | (NonNullable & { + readonly status: OrchestrationSessionStatus; + }) + | null; + }) + | null + | undefined; + readonly shellStatus: EnvironmentShellStatus; + readonly environmentConnected: boolean; + readonly providers: ReadonlyArray | null | undefined; + readonly project: { readonly workspaceRoot: string | null | undefined } | null | undefined; +}): ConfirmedThreadOutboxPlan { + if (input.message.deliveryHold !== undefined) return { action: "wait" }; + const creation = input.message.creation; + if (creation === undefined && input.thread == null) { + if (input.shellStatus !== "live") return { action: "wait" }; + return { + action: "hold", + hold: { + kind: "thread-missing", + reason: + "This thread was deleted on another device before the pending send landed. Retarget it to a new thread or delete it.", + }, + ...(input.message.destination === undefined ? {} : { creation: input.message.destination }), + }; + } + + const deliveryAction = resolveThreadOutboxDeliveryAction({ + isCreation: creation !== undefined, + threadExists: input.thread != null, + shellStatus: input.shellStatus, + environmentConnected: input.environmentConnected, + threadStatus: input.thread?.session?.status ?? null, + }); + if (deliveryAction === "wait") return { action: "wait" }; + if (deliveryAction === "remove") return { action: "remove" }; + + if (creation === undefined) { + const admission = resolveQueuedThreadAdmission({ + message: input.message, + thread: input.thread!, + providers: input.providers, + }); + return admission.action === "send" + ? { action: "send-existing", settings: admission.settings } + : admission; + } + if (!isQueuedThreadCreationSendable(input.message)) return { action: "wait" }; + const workspaceHold = queuedCreationWorkspaceHold({ + message: input.message, + project: input.project, + shellStatus: input.shellStatus, + }); + if (workspaceHold !== null) return { action: "hold", hold: workspaceHold }; + const admission = resolveQueuedCreationAdmission({ + message: input.message, + providers: input.providers, + }); + if (admission.action !== "send") return admission; + const projectCwd = input.project?.workspaceRoot?.trim(); + return projectCwd + ? { action: "send-creation", projectCwd } + : { + action: "hold", + hold: { + kind: "project-workspace-unavailable", + reason: + "The queued task's project workspace is missing. Retarget it to a valid project or delete it.", + }, + }; } export type ThreadOutboxDispatchStep = + | { readonly step: "confirm" } | { readonly step: "wait" } | { readonly step: "remove" } | { readonly step: "retry" } @@ -255,19 +602,15 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { } export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; -export type ThreadOutboxFailureAction = "retry" | "restore"; +export type ThreadOutboxFailureAction = "retry" | "hold"; export function resolveThreadOutboxFailureAction(input: { readonly stage: ThreadOutboxCommandStage; readonly error: unknown; readonly interrupted: boolean; }): ThreadOutboxFailureAction { - if ( - input.stage === "settings-sync" || - input.interrupted || - shouldRetryThreadOutboxDelivery(input.error) - ) { + if (input.interrupted || shouldRetryThreadOutboxDelivery(input.error)) { return "retry"; } - return "restore"; + return "hold"; } diff --git a/apps/mobile/src/state/thread-outbox-recovery.test.ts b/apps/mobile/src/state/thread-outbox-recovery.test.ts new file mode 100644 index 000000000..e81d371a2 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-recovery.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; + +import { recoverPendingSendToComposer } from "./thread-outbox-recovery"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +function heldMessage(): QueuedThreadMessage { + return { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-held"), + commandId: CommandId.make("command-held"), + text: "exact held text", + attachments: [ + { + id: "held-image", + previewUri: "file:///held.png", + type: "image", + name: "held.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AQ==", + }, + ], + modelSelection: { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + deliveryHold: { + kind: "provider-binding-mismatch", + reason: "Choose a destination.", + }, + createdAt: "2026-09-01T00:00:00.000Z", + }; +} + +describe("pending send composer recovery", () => { + it("durably flushes the exact payload before CAS-removing the queue item", async () => { + const message = heldMessage(); + const order: string[] = []; + const restore = vi.fn((draftKey: string, snapshot: unknown) => { + order.push(`restore:${draftKey}`); + expect(snapshot).toEqual({ + text: message.text, + attachments: message.attachments, + modelSelection: message.modelSelection, + runtimeMode: message.runtimeMode, + interactionMode: message.interactionMode, + }); + }); + const flushDraft = vi.fn(async () => { + order.push("flush"); + }); + const removeIfCurrent = vi.fn(async (candidate: QueuedThreadMessage) => { + order.push("remove"); + expect(candidate).toBe(message); + return true; + }); + + await expect( + recoverPendingSendToComposer( + { message, draftKey: "environment-1:thread-1" }, + { restore, flushDraft, removeIfCurrent }, + ), + ).resolves.toBe("removed"); + expect(order).toEqual(["restore:environment-1:thread-1", "flush", "remove"]); + }); + + it("keeps the queue item when the durable draft flush fails", async () => { + const message = heldMessage(); + const flushError = new Error("draft disk full"); + const removeIfCurrent = vi.fn(async () => true); + + await expect( + recoverPendingSendToComposer( + { message, draftKey: "environment-1:thread-1" }, + { + restore: () => {}, + flushDraft: async () => { + throw flushError; + }, + removeIfCurrent, + }, + ), + ).rejects.toBe(flushError); + expect(removeIfCurrent).not.toHaveBeenCalled(); + }); + + it("reports a concurrent queue CAS loss without deleting the newer item", async () => { + const message = heldMessage(); + const removeIfCurrent = vi.fn(async () => false); + + await expect( + recoverPendingSendToComposer( + { message, draftKey: "environment-1:thread-1" }, + { restore: () => {}, flushDraft: async () => {}, removeIfCurrent }, + ), + ).resolves.toBe("queue-changed"); + expect(removeIfCurrent).toHaveBeenCalledWith(message); + }); +}); diff --git a/apps/mobile/src/state/thread-outbox-recovery.ts b/apps/mobile/src/state/thread-outbox-recovery.ts new file mode 100644 index 000000000..45ba34482 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-recovery.ts @@ -0,0 +1,58 @@ +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { + flushComposerDrafts, + restorePendingSendComposerDraft, + type ComposerDraftWorkspaceSelection, + type PendingSendComposerSnapshot, +} from "./use-composer-drafts"; +import { removeThreadOutboxMessageIfCurrent } from "./thread-outbox-removal"; + +export type PendingSendRecoveryResult = "removed" | "queue-changed"; + +export function pendingSendComposerSnapshot( + message: QueuedThreadMessage, + workspaceSelection?: ComposerDraftWorkspaceSelection, +): PendingSendComposerSnapshot { + return { + text: message.text, + attachments: message.attachments, + ...(message.modelSelection === undefined ? {} : { modelSelection: message.modelSelection }), + ...(message.runtimeMode === undefined ? {} : { runtimeMode: message.runtimeMode }), + ...(message.interactionMode === undefined ? {} : { interactionMode: message.interactionMode }), + ...(workspaceSelection === undefined ? {} : { workspaceSelection }), + }; +} + +/** + * Recovery protocol for a held send: + * 1. merge its exact payload and selection into the chosen composer; + * 2. force that composer snapshot to durable storage; + * 3. CAS-remove only the queue object the user opened. + * + * A crash or flush failure before step 3 leaves the original held item intact. + * A concurrent retry makes step 3 return `queue-changed`, so the newer queued + * item survives while the restored composer copy remains available to edit. + */ +export async function recoverPendingSendToComposer( + input: { + readonly message: QueuedThreadMessage; + readonly draftKey: string; + readonly workspaceSelection?: ComposerDraftWorkspaceSelection; + }, + dependencies: { + readonly restore: (draftKey: string, snapshot: PendingSendComposerSnapshot) => void; + readonly flushDraft: () => Promise; + readonly removeIfCurrent: (message: QueuedThreadMessage) => Promise; + } = { + restore: restorePendingSendComposerDraft, + flushDraft: flushComposerDrafts, + removeIfCurrent: removeThreadOutboxMessageIfCurrent, + }, +): Promise { + dependencies.restore( + input.draftKey, + pendingSendComposerSnapshot(input.message, input.workspaceSelection), + ); + await dependencies.flushDraft(); + return (await dependencies.removeIfCurrent(input.message)) ? "removed" : "queue-changed"; +} diff --git a/apps/mobile/src/state/thread-outbox-removal.ts b/apps/mobile/src/state/thread-outbox-removal.ts index d78a0a38b..9e1ac3339 100644 --- a/apps/mobile/src/state/thread-outbox-removal.ts +++ b/apps/mobile/src/state/thread-outbox-removal.ts @@ -81,6 +81,21 @@ export async function removeThreadOutboxMessage( return true; } +/** + * Removes only the exact queue snapshot the caller inspected, then releases + * the files owned by that snapshot. + */ +export async function removeThreadOutboxMessageIfCurrent( + message: QueuedThreadMessage, +): Promise { + const removed = await threadOutboxManager.removeIfCurrent(message); + if (!removed) { + return false; + } + await cleanUpRemovedMessages([message]); + return true; +} + /** Removes every queued message of an environment and releases their files. */ export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise { // clearEnvironment loads and merges persisted messages itself and reports diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 0069064f3..8daeb0677 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -4,8 +4,10 @@ import { EnvironmentId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, + type ServerProvider, } from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -15,9 +17,15 @@ import { groupQueuedThreadMessages, isQueuedThreadCreationSendable, modelSelectionsEqual, + preserveQueuedThreadDeliveryHold, + queuedCreationWorkspaceHold, + resolveConfirmedThreadOutboxPlan, + resolveHeldSendSelectedProvider, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, + resolveQueuedThreadAdmission, + retryQueuedThreadMessage, resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, @@ -43,6 +51,34 @@ function queuedMessage(input: { }; } +function provider(input: { + readonly instanceId: string; + readonly availability?: "available" | "unavailable"; + readonly unavailableReason?: string; + readonly status?: ServerProvider["status"]; + readonly driver?: string; + readonly continuationGroupKey?: string; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver ?? "primeAgent"), + enabled: input.availability !== "unavailable", + installed: true, + version: null, + status: input.status ?? (input.availability === "unavailable" ? "disabled" : "ready"), + ...(input.availability ? { availability: input.availability } : {}), + ...(input.unavailableReason ? { unavailableReason: input.unavailableReason } : {}), + ...(input.continuationGroupKey + ? { continuation: { groupKey: input.continuationGroupKey } } + : {}), + auth: { status: "authenticated" }, + checkedAt: "2026-08-06T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + describe("thread outbox", () => { it("groups messages by scoped thread and preserves creation order", () => { const later = queuedMessage({ @@ -134,6 +170,305 @@ describe("thread outbox", () => { }); }); + it("durably holds a queued turn whose snapshot conflicts with the live binding", () => { + const message = { + ...queuedMessage({ + messageId: "message-binding", + createdAt: "2026-06-08T10:00:01.000Z", + }), + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + } satisfies QueuedThreadMessage; + const admission = resolveQueuedThreadAdmission({ + message, + thread: { + modelSelection: { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "kimi-k2.5", + }, + runtimeMode: "full-access", + interactionMode: "default", + session: { providerInstanceId: ProviderInstanceId.make("primeAgent") }, + }, + providers: [provider({ instanceId: "primeAgent" })], + }); + + expect(admission.action).toBe("hold"); + if (admission.action !== "hold") return; + expect(admission.hold.kind).toBe("provider-binding-mismatch"); + expect( + decodeQueuedThreadMessage( + encodeQueuedThreadMessage({ ...message, deliveryHold: admission.hold }), + ).deliveryHold, + ).toEqual(admission.hold); + }); + + it("dispatches a compatible account with the target-owned model and options intact", () => { + const targetSelection = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "approval-required" as const, + interactionMode: "default" as const, + session: { providerInstanceId: ProviderInstanceId.make("codex") }, + }; + const admission = resolveQueuedThreadAdmission({ + message: { + ...queuedMessage({ + messageId: "message-compatible-account", + createdAt: "2026-06-08T10:00:01.000Z", + }), + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + }, + thread, + providers: [ + provider({ + instanceId: "codex", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }), + provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }), + ], + }); + + expect(admission).toEqual({ + action: "send", + settings: { + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + session: thread.session, + }, + }); + }); + + it("offers held-send retarget only for an available exact continuation peer", () => { + const selected = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + const bound = provider({ + instanceId: "codex", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + const compatible = provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + + expect( + resolveHeldSendSelectedProvider({ + boundInstanceId: bound.instanceId, + selectedModelSelection: selected, + providers: [bound, compatible], + }), + ).toBe(selected); + expect( + resolveHeldSendSelectedProvider({ + boundInstanceId: bound.instanceId, + selectedModelSelection: selected, + providers: [ + bound, + provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:other", + }), + ], + }), + ).toBeNull(); + expect( + resolveHeldSendSelectedProvider({ + boundInstanceId: bound.instanceId, + selectedModelSelection: selected, + providers: [ + bound, + provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:shared", + availability: "unavailable", + unavailableReason: "Install this account first.", + }), + ], + }), + ).toBeNull(); + expect( + resolveHeldSendSelectedProvider({ + boundInstanceId: bound.instanceId, + selectedModelSelection: selected, + providers: [bound], + }), + ).toBeNull(); + }); + + it("holds exact unavailable bindings with remediation and resumes the same instance", () => { + const selection = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "kimi-k2.5", + options: [{ id: "thinking", value: true }], + } as const; + const message = { + ...queuedMessage({ + messageId: "message-unavailable", + createdAt: "2026-06-08T10:00:01.000Z", + }), + modelSelection: selection, + } satisfies QueuedThreadMessage; + const thread = { + modelSelection: selection, + runtimeMode: "approval-required" as const, + interactionMode: "plan" as const, + session: { providerInstanceId: selection.instanceId }, + }; + const reason = "Restore Prime Agent inside WSL2."; + + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [ + provider({ + instanceId: "primeAgent", + availability: "unavailable", + unavailableReason: reason, + }), + ], + }), + ).toMatchObject({ + action: "hold", + hold: { kind: "provider-unavailable", reason }, + }); + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [provider({ instanceId: "primeAgent", availability: "available" })], + }), + ).toEqual({ action: "send", settings: thread }); + }); + + it("waits on unknown provider snapshots, admits warnings, and holds hard errors", () => { + const message = queuedMessage({ + messageId: "message-provider-tri-state", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + }; + + expect(resolveQueuedThreadAdmission({ message, thread, providers: undefined })).toEqual({ + action: "wait", + }); + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [provider({ instanceId: "codex", status: "warning" })], + }).action, + ).toBe("send"); + expect( + resolveQueuedThreadAdmission({ + message, + thread, + providers: [provider({ instanceId: "codex", status: "error" })], + }).action, + ).toBe("hold"); + }); + + it("keeps persisted holds inert for existing turns and task creation", () => { + for (const isCreation of [false, true]) { + expect( + resolveThreadOutboxDeliveryAction({ + isCreation, + threadExists: !isCreation, + shellStatus: "live", + environmentConnected: true, + threadStatus: "ready", + hasDeliveryHold: true, + }), + ).toBe("wait"); + } + }); + + it("preserves a held creation only while an edit keeps every durable identifier", () => { + const held = { + ...queuedMessage({ + messageId: "message-held-edit", + createdAt: "2026-06-08T10:00:01.000Z", + }), + deliveryHold: { + kind: "project-workspace-unavailable" as const, + reason: "Retarget this task.", + }, + }; + const identity = { + threadId: held.threadId, + commandId: held.commandId, + messageId: held.messageId, + createdAt: held.createdAt, + }; + + expect(preserveQueuedThreadDeliveryHold(held, identity)).toBe(held.deliveryHold); + expect( + preserveQueuedThreadDeliveryHold(held, { + ...identity, + commandId: CommandId.make("command-explicit-retry"), + }), + ).toBeUndefined(); + expect(preserveQueuedThreadDeliveryHold(null, identity)).toBeUndefined(); + }); + + it("mints a fresh admission request when a held send is retried", () => { + const original = { + ...queuedMessage({ + messageId: "message-held-retry", + createdAt: "2026-06-08T10:00:01.000Z", + }), + text: "preserve me", + deliveryHold: { + kind: "admission-rejected" as const, + reason: "PreviouslyRejected", + }, + }; + const retried = retryQueuedThreadMessage(original, { + commandId: CommandId.make("command-held-retry-fresh"), + createdAt: "2026-06-08T10:00:02.000Z", + }); + + expect(retried).toMatchObject({ + messageId: original.messageId, + commandId: CommandId.make("command-held-retry-fresh"), + text: "preserve me", + createdAt: "2026-06-08T10:00:02.000Z", + }); + expect(retried.commandId).not.toBe(original.commandId); + expect(retried.deliveryHold).toBeUndefined(); + }); + it("compares model options as part of the queued settings change", () => { const base = { instanceId: ProviderInstanceId.make("codex"), @@ -948,14 +1283,57 @@ describe("thread outbox", () => { ).toEqual({ step: "send" }); }); - it("only removes a missing-thread message after shell synchronization is live", () => { + it("CAS recovery never removes or overwrites a concurrently replaced queue item", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async (message) => { + stored.delete(message.messageId); + }, + }, + }); + const original = queuedMessage({ + messageId: "message-cas", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const concurrentRetry = { + ...original, + commandId: CommandId.make("command-concurrent-retry"), + text: "newer queued content", + }; + + await manager.enqueue(original); + await manager.update(concurrentRetry); + + await expect(manager.removeIfCurrent(original)).resolves.toBe(false); + await expect( + manager.updateIfCurrent(original, { ...original, text: "stale recovery write" }), + ).resolves.toBe(false); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [concurrentRetry], + }); + expect(stored.get(original.messageId)).toEqual(concurrentRetry); + + await expect(manager.removeIfCurrent(concurrentRetry)).resolves.toBe(true); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + expect(stored.size).toBe(0); + registry.dispose(); + }); + + it("only confirms a missing-thread message after shell synchronization is live", () => { expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("wait"); expect( @@ -964,28 +1342,37 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), - ).toBe("remove"); + ).toBe("confirm"); expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("send"); }); - it("sends existing-thread messages whenever connected so queued messages can steer", () => { + it("waits for starting admission but lets running sessions steer", () => { expect( resolveThreadOutboxDeliveryAction({ isCreation: false, threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: true, + threadStatus: "starting", + }), + ).toBe("wait"); + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: true, + threadStatus: "running", }), ).toBe("send"); expect( @@ -994,11 +1381,178 @@ describe("thread outbox", () => { threadExists: true, shellStatus: "live", environmentConnected: false, - threadBusy: true, + threadStatus: "running", }), ).toBe("wait"); }); + it("recomputes every live authority after durable confirmation", () => { + const message = queuedMessage({ + messageId: "message-post-confirm-existing", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + session: { status: "ready" as const }, + }; + const destination = { + projectId: ProjectId.make("project-post-confirm"), + projectTitle: "Project", + projectCwd: "/workspace/current", + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }; + const base = { + message: { ...message, destination }, + thread, + shellStatus: "live" as const, + environmentConnected: true, + providers: [provider({ instanceId: "codex", status: "warning" })], + project: null, + }; + + expect(resolveConfirmedThreadOutboxPlan(base).action).toBe("send-existing"); + expect(resolveConfirmedThreadOutboxPlan({ ...base, environmentConnected: false }).action).toBe( + "wait", + ); + expect( + resolveConfirmedThreadOutboxPlan({ + ...base, + thread: { ...thread, session: { status: "starting" } }, + }).action, + ).toBe("wait"); + expect(resolveConfirmedThreadOutboxPlan({ ...base, thread: undefined })).toMatchObject({ + action: "hold", + hold: { kind: "thread-missing" }, + creation: destination, + }); + expect(resolveConfirmedThreadOutboxPlan({ ...base, providers: undefined }).action).toBe("wait"); + + const creationMessage = { + ...message, + modelSelection: thread.modelSelection, + creation: { + projectId: ProjectId.make("project-post-confirm"), + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + startFromOrigin: false, + }, + }; + const creationBase = { + ...base, + message: creationMessage, + thread: undefined, + project: { workspaceRoot: "/workspace/current" }, + }; + expect(resolveConfirmedThreadOutboxPlan(creationBase)).toMatchObject({ + action: "send-creation", + projectCwd: "/workspace/current", + }); + expect(resolveConfirmedThreadOutboxPlan({ ...creationBase, project: undefined })).toMatchObject( + { action: "hold", hold: { kind: "project-workspace-unavailable" } }, + ); + expect(resolveConfirmedThreadOutboxPlan({ ...creationBase, providers: undefined }).action).toBe( + "wait", + ); + }); + + it("quiesces after a cross-device delete is converted to a durable hold", () => { + const destination = { + projectId: ProjectId.make("project-cross-device-delete"), + projectTitle: "Cross-device project", + projectCwd: "/workspace/cross-device", + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }; + const message = { + ...queuedMessage({ + messageId: "message-cross-device-delete", + createdAt: "2026-06-08T10:00:01.000Z", + }), + destination, + }; + const confirmed = resolveConfirmedThreadOutboxPlan({ + message, + thread: undefined, + shellStatus: "live", + environmentConnected: true, + providers: [provider({ instanceId: "codex", status: "ready" })], + project: null, + }); + expect(confirmed).toMatchObject({ + action: "hold", + hold: { kind: "thread-missing" }, + creation: destination, + }); + if (confirmed.action !== "hold" || confirmed.creation === undefined) { + throw new Error("Expected retargetable missing-thread hold"); + } + const held = { + ...message, + deliveryHold: confirmed.hold, + creation: confirmed.creation, + }; + + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: true, + threadExists: false, + shellStatus: "live", + environmentConnected: true, + threadStatus: null, + hasDeliveryHold: true, + }), + ).toBe("wait"); + expect( + resolveConfirmedThreadOutboxPlan({ + message: held, + thread: undefined, + shellStatus: "live", + environmentConnected: true, + providers: [provider({ instanceId: "codex", status: "ready" })], + project: { workspaceRoot: "/workspace/cross-device" }, + }), + ).toEqual({ action: "wait" }); + }); + + it("turns a live missing project workspace into a durable creation hold", () => { + const message = { + ...queuedMessage({ + messageId: "message-missing-project", + createdAt: "2026-06-08T10:00:01.000Z", + }), + creation: { + projectId: ProjectId.make("project-missing"), + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + startFromOrigin: false, + }, + }; + + expect( + queuedCreationWorkspaceHold({ message, project: undefined, shellStatus: "cached" }), + ).toBe(null); + expect( + queuedCreationWorkspaceHold({ message, project: undefined, shellStatus: "live" }), + ).toMatchObject({ kind: "project-workspace-unavailable" }); + expect( + queuedCreationWorkspaceHold({ + message, + project: { workspaceRoot: "/workspace/retargeted" }, + shellStatus: "live", + }), + ).toBe(null); + }); + it("sends queued creations once connected and live, removing already-created ones", () => { expect( resolveThreadOutboxDeliveryAction({ @@ -1006,7 +1560,7 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "cached", environmentConnected: false, - threadBusy: false, + threadStatus: null, }), ).toBe("wait"); // Connected but not yet synchronized: a previously delivered creation may @@ -1017,7 +1571,7 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "synchronizing", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("wait"); expect( @@ -1026,7 +1580,7 @@ describe("thread outbox", () => { threadExists: false, shellStatus: "live", environmentConnected: true, - threadBusy: false, + threadStatus: null, }), ).toBe("send"); expect( @@ -1035,7 +1589,7 @@ describe("thread outbox", () => { threadExists: true, shellStatus: "live", environmentConnected: true, - threadBusy: true, + threadStatus: "running", }), ).toBe("remove"); }); @@ -1093,8 +1647,8 @@ describe("thread outbox", () => { expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); }); - it("retains queued messages when settings synchronization fails before startTurn", () => { - const deterministicFailure = new Error("Thread no longer exists"); + it("holds every domain failure before acceptance without losing content", () => { + const deterministicFailure = new Error("Thread already has pending turn admission"); expect( resolveThreadOutboxFailureAction({ @@ -1102,13 +1656,37 @@ describe("thread outbox", () => { error: deterministicFailure, interrupted: false, }), - ).toBe("retry"); + ).toBe("hold"); expect( resolveThreadOutboxFailureAction({ stage: "start-turn", error: deterministicFailure, interrupted: false, }), - ).toBe("restore"); + ).toBe("hold"); + + const message = { + ...queuedMessage({ + messageId: "message-admission-rejected", + createdAt: "2026-06-08T10:00:01.000Z", + }), + text: "keep this pending prose", + attachments: [ + { + id: "image-1", + previewUri: "file:///preview.png", + type: "image" as const, + name: "preview.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + deliveryHold: { + kind: "admission-rejected" as const, + reason: deterministicFailure.message, + }, + }; + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); }); }); diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts index 2f9d8c854..707fc6d15 100644 --- a/apps/mobile/src/state/thread-outbox.ts +++ b/apps/mobile/src/state/thread-outbox.ts @@ -51,6 +51,13 @@ export function threadOutboxRevision(messageId: QueuedThreadMessage["messageId"] return threadOutboxManager.revisionOf(messageId); } +export function updateThreadOutboxMessageIfCurrent( + expected: QueuedThreadMessage, + replacement: QueuedThreadMessage, +): Promise { + return threadOutboxManager.updateIfCurrent(expected, replacement); +} + // Removal lives in `thread-outbox-removal.ts`: taking a message out of the // outbox must also release its local attachment files, and that owner needs // the composer draft state this module must not depend on. diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 3ab0baa39..558f4ef8f 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -135,6 +135,7 @@ import { removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, restoreComposerDraftSnapshotState, + restorePendingSendComposerDraftState, setComposerDraftText, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, @@ -609,6 +610,65 @@ describe("mobile composer drafts", () => { } }); + it("restores a held send with exact content, attachments, and selection without truncation", () => { + const existingAttachment = { + id: "existing-image", + previewUri: "file:///existing.png", + type: "image" as const, + name: "existing.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "data:image/png;base64,AA==", + }; + const heldAttachment = { + id: "held-image", + previewUri: "file:///held.png", + type: "image" as const, + name: "held.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AQ==", + }; + const selection = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + const initial = { + "environment-1:thread-1": { + text: "newer local text", + attachments: [existingAttachment], + }, + }; + + const restored = restorePendingSendComposerDraftState(initial, "environment-1:thread-1", { + text: "held exact text", + attachments: [heldAttachment], + modelSelection: selection, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + expect(restored["environment-1:thread-1"]).toEqual({ + text: "newer local text\n\nheld exact text", + attachments: [existingAttachment, heldAttachment], + modelSelection: selection, + providerSelectionExplicit: true, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + // A crash after the draft flush but before queue removal can replay this + // restore after restart. It must not duplicate either payload slice. + expect( + restorePendingSendComposerDraftState(restored, "environment-1:thread-1", { + text: "held exact text", + attachments: [heldAttachment], + modelSelection: selection, + runtimeMode: "approval-required", + interactionMode: "plan", + }), + ).toEqual(restored); + }); + it("hydrates selector state even when the message content is empty", () => { expect( decodePersistedComposerDrafts({ @@ -622,6 +682,7 @@ describe("mobile composer drafts", () => { model: "gpt-5.4", options: [{ id: "reasoningEffort", value: "xhigh" }], }, + providerSelectionExplicit: true, runtimeMode: "approval-required", interactionMode: "plan", workspaceSelection: { @@ -641,6 +702,7 @@ describe("mobile composer drafts", () => { model: "gpt-5.4", options: [{ id: "reasoningEffort", value: "xhigh" }], }, + providerSelectionExplicit: true, runtimeMode: "approval-required", interactionMode: "plan", workspaceSelection: { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7d243360f..64dc26e14 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -45,6 +45,8 @@ export interface ComposerDraft { readonly attachments: ReadonlyArray; readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; + /** True when a human/recovered send chose the exact provider account. */ + readonly providerSelectionExplicit?: boolean; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; @@ -65,9 +67,22 @@ export interface ComposerDraftWorkspaceSelection { export type ComposerDraftSettingsUpdate = Pick< ComposerDraft, - "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" + | "modelSelection" + | "providerSelectionExplicit" + | "runtimeMode" + | "interactionMode" + | "workspaceSelection" >; +export interface PendingSendComposerSnapshot { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly workspaceSelection?: ComposerDraftWorkspaceSelection; +} + const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ mode: Schema.Literals(["local", "worktree"]), branch: Schema.NullOr(Schema.String), @@ -80,6 +95,7 @@ const ComposerDraftSchema = Schema.Struct({ attachments: Schema.Array(DraftComposerAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), + providerSelectionExplicit: Schema.optional(Schema.Boolean), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), @@ -143,6 +159,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { draft.text.length === 0 && draft.attachments.length === 0 && draft.modelSelection === undefined && + draft.providerSelectionExplicit === undefined && draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined @@ -169,6 +186,7 @@ export function decodePersistedComposerState(value: unknown): { // attachments were deliberately configured and are left alone. key.startsWith("new-task:") && draft.modelSelection && + draft.providerSelectionExplicit !== true && draft.text.length === 0 && draft.attachments.length === 0 && draft.runtimeMode === undefined && @@ -660,6 +678,62 @@ export function updateComposerDraftSettings( }); } +export function restorePendingSendComposerDraftState( + current: Record, + draftKey: string, + snapshot: PendingSendComposerSnapshot, +): Record { + const existing = normalizeDraft(current[draftKey]); + const attachmentIds = new Set(existing.attachments.map((attachment) => attachment.id)); + const restoredAttachments = [ + ...existing.attachments, + ...snapshot.attachments.filter((attachment) => { + if (attachmentIds.has(attachment.id)) return false; + attachmentIds.add(attachment.id); + return true; + }), + ]; + const restored: ComposerDraft = { + ...existing, + text: mergeComposerDraftText(existing.text, snapshot.text), + // Recovery is intentionally uncapped. A user may already have started a + // new draft while the hold was open; truncating either set here would be + // irreversible. The composer can ask them to remove extras before send. + attachments: restoredAttachments, + ...(snapshot.modelSelection === undefined + ? {} + : { modelSelection: snapshot.modelSelection, providerSelectionExplicit: true }), + ...(snapshot.runtimeMode === undefined ? {} : { runtimeMode: snapshot.runtimeMode }), + ...(snapshot.interactionMode === undefined + ? {} + : { interactionMode: snapshot.interactionMode }), + ...(snapshot.workspaceSelection === undefined + ? {} + : { workspaceSelection: snapshot.workspaceSelection }), + }; + if ( + existing.text === restored.text && + existing.attachments.length === restored.attachments.length && + existing.modelSelection === restored.modelSelection && + existing.providerSelectionExplicit === restored.providerSelectionExplicit && + existing.runtimeMode === restored.runtimeMode && + existing.interactionMode === restored.interactionMode && + existing.workspaceSelection === restored.workspaceSelection + ) { + return current; + } + return { ...current, [draftKey]: restored }; +} + +export function restorePendingSendComposerDraft( + draftKey: string, + snapshot: PendingSendComposerSnapshot, +): void { + updateComposerDrafts((current) => + restorePendingSendComposerDraftState(current, draftKey, snapshot), + ); +} + export function clearComposerDraftContentState( current: Record, draftKey: string, @@ -675,12 +749,18 @@ export function clearComposerDraftContentState( const { importedShareIds: _importedShareIds, modelSelection, + providerSelectionExplicit, workspaceSelection, ...retained } = existing; const draft = { ...retained, - ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), + ...(options?.clearModelSelection || modelSelection === undefined + ? {} + : { + modelSelection, + ...(providerSelectionExplicit === undefined ? {} : { providerSelectionExplicit }), + }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), diff --git a/apps/mobile/src/state/use-thread-composer-state.logic.ts b/apps/mobile/src/state/use-thread-composer-state.logic.ts new file mode 100644 index 000000000..d6e6cc3ba --- /dev/null +++ b/apps/mobile/src/state/use-thread-composer-state.logic.ts @@ -0,0 +1,77 @@ +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; +import type { + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ServerProvider, +} from "@t3tools/contracts"; + +export interface ExistingThreadComposerSettings { + readonly modelSelection: ModelSelection; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +interface DraftComposerSettings { + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; +} + +/** + * A persisted provider session owns the continuation identity for an existing + * thread. Device-local draft settings may use the bound instance or an exact + * same-driver continuation peer. Every accepted selection stays intact; model + * and options are never transplanted onto another instance id. + */ +export function resolveExistingThreadComposerSettings(input: { + readonly thread: ExistingThreadComposerSettings; + readonly sessionProviderInstanceId?: ModelSelection["instanceId"] | undefined; + readonly providers?: ReadonlyArray | undefined; + readonly draft?: DraftComposerSettings | null | undefined; +}): Omit & { + readonly modelSelection: ModelSelection | null; + readonly rejectedDraftProviderSelection: boolean; + readonly providerBindingMismatch: boolean; +} { + const boundInstanceId = input.sessionProviderInstanceId; + const draftSelection = input.draft?.modelSelection; + const draftTransition = + boundInstanceId !== undefined && + draftSelection !== undefined && + draftSelection.instanceId !== boundInstanceId + ? resolveProviderContinuationTransition({ + providers: input.providers ?? [], + currentInstanceId: boundInstanceId, + targetInstanceId: draftSelection.instanceId, + }) + : null; + const rejectedDraftProviderSelection = draftTransition?.compatible === false; + + // A conflicting local draft is a durable blocked choice, not a stale seed. + // Keep its exact model/options/modes visible until the user explicitly picks + // a compatible continuation peer or moves the content into a new thread. + const modelSelection = rejectedDraftProviderSelection + ? (draftSelection ?? null) + : boundInstanceId === undefined + ? (draftSelection ?? input.thread.modelSelection) + : draftSelection !== undefined && + (draftSelection.instanceId === boundInstanceId || draftTransition?.compatible === true) + ? draftSelection + : input.thread.modelSelection.instanceId === boundInstanceId + ? input.thread.modelSelection + : null; + + return { + modelSelection, + runtimeMode: + input.draft?.runtimeMode !== undefined ? input.draft.runtimeMode : input.thread.runtimeMode, + interactionMode: + input.draft?.interactionMode !== undefined + ? input.draft.interactionMode + : input.thread.interactionMode, + rejectedDraftProviderSelection, + providerBindingMismatch: + rejectedDraftProviderSelection || (boundInstanceId !== undefined && modelSelection === null), + }; +} diff --git a/apps/mobile/src/state/use-thread-composer-state.test.ts b/apps/mobile/src/state/use-thread-composer-state.test.ts new file mode 100644 index 000000000..f336b62cf --- /dev/null +++ b/apps/mobile/src/state/use-thread-composer-state.test.ts @@ -0,0 +1,142 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveExistingThreadComposerSettings } from "./use-thread-composer-state.logic"; + +const selection = (instanceId: string, model: string) => ({ + instanceId: ProviderInstanceId.make(instanceId), + model, +}); + +const primeThread = { + modelSelection: selection("primeAgent", "prime-model"), + runtimeMode: "full-access" as const, + interactionMode: "default" as const, +}; + +function provider(instanceId: string, continuationGroupKey: string): ServerProvider { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make("codex"), + continuation: { groupKey: continuationGroupKey }, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +describe("resolveExistingThreadComposerSettings", () => { + it("rejects a device-local Codex draft on a Prime-bound session", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: primeThread, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + draft: { + modelSelection: selection("codex", "gpt-5-codex"), + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }), + ).toEqual({ + modelSelection: selection("codex", "gpt-5-codex"), + runtimeMode: "approval-required", + interactionMode: "plan", + rejectedDraftProviderSelection: true, + providerBindingMismatch: true, + }); + }); + + it("keeps same-instance model and mode changes on the bound session", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: primeThread, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + draft: { + modelSelection: selection("primeAgent", "prime-model-max"), + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }), + ).toEqual({ + modelSelection: selection("primeAgent", "prime-model-max"), + runtimeMode: "approval-required", + interactionMode: "plan", + rejectedDraftProviderSelection: false, + providerBindingMismatch: false, + }); + }); + + it("keeps the target account's intact model and options for a compatible transition", () => { + const targetSelection = { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + } as const; + expect( + resolveExistingThreadComposerSettings({ + thread: { + modelSelection: selection("codex", "gpt-5.3-codex"), + runtimeMode: "approval-required", + interactionMode: "default", + }, + sessionProviderInstanceId: ProviderInstanceId.make("codex"), + providers: [ + provider("codex", "codex:home:shared"), + provider("codex_personal", "codex:home:shared"), + ], + draft: { + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + }, + }), + ).toEqual({ + modelSelection: targetSelection, + runtimeMode: "full-access", + interactionMode: "plan", + rejectedDraftProviderSelection: false, + providerBindingMismatch: false, + }); + }); + + it("blocks instead of transplanting a persisted selection across the live binding", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: { + ...primeThread, + modelSelection: selection("codex", "gpt-5-codex"), + }, + sessionProviderInstanceId: ProviderInstanceId.make("primeAgent"), + }), + ).toMatchObject({ + modelSelection: null, + providerBindingMismatch: true, + }); + }); + + it("keeps the explicit draft selection for an unbound new session", () => { + expect( + resolveExistingThreadComposerSettings({ + thread: primeThread, + draft: { modelSelection: selection("codex", "gpt-5-codex") }, + }), + ).toMatchObject({ + modelSelection: selection("codex", "gpt-5-codex"), + rejectedDraftProviderSelection: false, + }); + }); + + it("keeps the persisted thread fallback when an unbound thread has no draft selection", () => { + expect(resolveExistingThreadComposerSettings({ thread: primeThread })).toEqual({ + ...primeThread, + rejectedDraftProviderSelection: false, + providerBindingMismatch: false, + }); + }); +}); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index fc81dc7b3..2eade205e 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import * as Cause from "effect/Cause"; @@ -15,6 +16,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -64,8 +66,9 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { prepareTurnAttachments, validateDraftFileAttachments } from "../lib/attachmentUpload"; -import { scopedThreadKey } from "../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; import { + canSendToModelSelection, resolveModelSelectionRuntimeMode, showModelSelectionInteractionModeToggle, } from "../lib/modelOptions"; @@ -88,13 +91,21 @@ import { useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; -import { useEnvironmentServerConfig } from "../state/entities"; +import { useEnvironmentServerConfig, useProjects } from "../state/entities"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; -import { enqueueThreadOutboxMessage } from "./thread-outbox"; +import { + enqueueThreadOutboxMessage, + resolveHeldSendSelectedProvider, + retryQueuedThreadMessage, + updateThreadOutboxMessageIfCurrent, +} from "./thread-outbox"; +import { removeThreadOutboxMessageIfCurrent } from "./thread-outbox-removal"; +import { recoverPendingSendToComposer } from "./thread-outbox-recovery"; import { useThreadOutboxMessages } from "./use-thread-outbox"; import { useAtomCommand } from "./use-atom-command"; import { threadEnvironment } from "./threads"; +import { resolveExistingThreadComposerSettings } from "./use-thread-composer-state.logic"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -135,7 +146,8 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState() { - const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); + const navigation = useNavigation(); + const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const selectedThreadContextWindow = useMemo( () => deriveLatestContextWindowSnapshot(selectedThreadDetail?.activities ?? []), @@ -144,6 +156,15 @@ export function useThreadComposerState() { const selectedThreadServerConfig = useEnvironmentServerConfig( selectedThreadShell?.environmentId ?? null, ); + const projects = useProjects(); + const selectedThreadProject = + selectedThreadShell == null + ? null + : (projects.find( + (project) => + project.environmentId === selectedThreadShell?.environmentId && + project.id === selectedThreadShell.projectId, + ) ?? null); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -233,7 +254,19 @@ export function useThreadComposerState() { const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadDetail ?? selectedThreadShell; - const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const selectedSessionProviderInstanceId = + selectedThreadDetail?.session?.providerInstanceId ?? + selectedThreadShell?.session?.providerInstanceId; + const composerSettings = selectedThread + ? resolveExistingThreadComposerSettings({ + thread: selectedThread, + sessionProviderInstanceId: selectedSessionProviderInstanceId, + providers: selectedThreadServerConfig?.providers ?? [], + draft: selectedDraft, + }) + : null; + const modelSelection = composerSettings?.modelSelection ?? null; + const selectedThreadResources = useMemo(() => { const session = selectedThreadDetail?.session; const instanceId = session?.providerInstanceId; @@ -295,6 +328,9 @@ export function useThreadComposerState() { environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, providerInstanceId: instanceId, + admissionAvailable: + selectedThreadDetail.modelSelection.instanceId === instanceId && + canSendToModelSelection(selectedThreadServerConfig, selectedThreadDetail.modelSelection), } as const; }, [ selectedThreadDetail?.session?.providerInstanceId, @@ -410,7 +446,13 @@ export function useThreadComposerState() { const runSessionCompactionMutation = useCallback( async (action: "compact" | "abort" | "auto-enable" | "auto-disable"): Promise => { const scope = sessionCompactionScopeRef.current; - if (!scope || sessionCompactionMutationRef.current?.scopeKey === scope.key) return false; + if ( + !scope || + sessionCompactionMutationRef.current?.scopeKey === scope.key || + (action !== "abort" && !scope.admissionAvailable) + ) { + return false; + } const id = ++sessionCompactionRequestIdRef.current; const mutation = { scopeKey: scope.key, id, action } as const; sessionCompactionMutationRef.current = mutation; @@ -470,21 +512,18 @@ export function useThreadComposerState() { }, [abortSessionCompaction, compactSession, setSessionAutoCompaction], ); - const selectedRuntimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; - const runtimeMode = selectedRuntimeMode + const runtimeMode = composerSettings ? resolveModelSelectionRuntimeMode( selectedThreadServerConfig, modelSelection, - selectedRuntimeMode, + composerSettings.runtimeMode, ) : null; - const selectedInteractionMode = - selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const interactionMode = showModelSelectionInteractionModeToggle( selectedThreadServerConfig, modelSelection, ) - ? selectedInteractionMode + ? (composerSettings?.interactionMode ?? null) : "default"; const selectedThreadSessionActivity = useMemo(() => { @@ -541,8 +580,22 @@ export function useThreadComposerState() { return null; } - const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( - (entry) => entry.instanceId === thread.modelSelection.instanceId, + const submissionSettings = resolveExistingThreadComposerSettings({ + thread, + sessionProviderInstanceId: selectedSessionProviderInstanceId, + providers: selectedThreadServerConfig?.providers ?? [], + draft, + }); + const modelSelection = submissionSettings.modelSelection; + if ( + submissionSettings.rejectedDraftProviderSelection || + modelSelection === null || + !canSendToModelSelection(selectedThreadServerConfig, modelSelection) + ) { + return null; + } + const provider = selectedThreadServerConfig?.providers.find( + (entry) => entry.instanceId === modelSelection.instanceId, ); const feedbackCommand = attachments.length === 0 && @@ -607,17 +660,16 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); - const modelSelection = draft.modelSelection ?? thread.modelSelection; const runtimeMode = resolveModelSelectionRuntimeMode( selectedThreadServerConfig, modelSelection, - draft.runtimeMode ?? thread.runtimeMode, + submissionSettings.runtimeMode, ); const interactionMode = showModelSelectionInteractionModeToggle( selectedThreadServerConfig, modelSelection, ) - ? (draft.interactionMode ?? thread.interactionMode) + ? submissionSettings.interactionMode : "default"; // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on @@ -634,6 +686,19 @@ export function useThreadComposerState() { modelSelection, runtimeMode, interactionMode, + destination: { + projectId: selectedThreadShell.projectId, + ...(selectedThreadProject?.title === undefined + ? {} + : { projectTitle: selectedThreadProject.title }), + ...(selectedThreadProject?.workspaceRoot === undefined + ? {} + : { projectCwd: selectedThreadProject.workspaceRoot }), + workspaceMode: selectedThreadShell.worktreePath === null ? "local" : "worktree", + branch: selectedThreadShell.branch, + // A deleted worktree cannot be reused. Retargeting creates a fresh one. + worktreePath: null, + }, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); @@ -658,9 +723,10 @@ export function useThreadComposerState() { ); return messageId; }, [ - selectedEnvironmentRuntime?.serverConfig?.providers, + selectedSessionProviderInstanceId, selectedThreadDetail, sessionCompactionBlocksSubmission, + selectedThreadProject, selectedThreadServerConfig, selectedThreadShell, uploadThreadFeedback, @@ -677,6 +743,19 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); + const submissionSettings = resolveExistingThreadComposerSettings({ + thread: selectedThreadDetail, + sessionProviderInstanceId: selectedSessionProviderInstanceId, + providers: selectedThreadServerConfig?.providers ?? [], + draft, + }); + if ( + submissionSettings.rejectedDraftProviderSelection || + submissionSettings.modelSelection === null || + !canSendToModelSelection(selectedThreadServerConfig, submissionSettings.modelSelection) + ) { + return null; + } const text = draft.text.trim(); const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) return null; @@ -764,8 +843,9 @@ export function useThreadComposerState() { return messageId; }, [ followUpInputQueue, - selectedThreadDetail?.session?.activeTurnId, - selectedThreadDetail?.session?.status, + selectedSessionProviderInstanceId, + selectedThreadDetail, + selectedThreadServerConfig, selectedThreadShell, sessionCompactionBlocksSubmission, ]); @@ -951,7 +1031,10 @@ export function useThreadComposerState() { ) ?? null); if ( !selectedThreadShell || + !selectedThreadDetail || (session?.status !== "ready" && session?.status !== "running") || + selectedThreadDetail.modelSelection.instanceId !== session.providerInstanceId || + !canSendToModelSelection(selectedThreadServerConfig, selectedThreadDetail.modelSelection) || !supportsSessionInputQueueSetModes(provider) || !hasSessionInputQueueModes(selectedThreadInputQueue) ) { @@ -1001,7 +1084,7 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const capabilities = selectedThreadServerConfig?.environment.capabilities; const result = await pickComposerMedia({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, maxVideoBytes: @@ -1019,15 +1102,14 @@ export function useThreadComposerState() { if (problems.length > 0) { Alert.alert("Could not attach photo or video", problems.join("\n\n")); } - }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); + }, [composerDrafts, selectedThreadServerConfig, selectedThreadShell]); const onPickDraftFiles = useCallback(async () => { if (!selectedThreadShell) { return; } const maxBytes = - selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.fileAttachments - ?.maxUploadBytes; + selectedThreadServerConfig?.environment.capabilities.fileAttachments?.maxUploadBytes; if (maxBytes === undefined) { Alert.alert("Could not attach file", "This server does not support file attachments."); return; @@ -1051,7 +1133,7 @@ export function useThreadComposerState() { if (problems.length > 0) { Alert.alert("Could not attach file", problems.join("\n\n")); } - }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); + }, [composerDrafts, selectedThreadServerConfig, selectedThreadShell]); const onPasteIntoDraft = useCallback(async () => { if (!selectedThreadShell) { @@ -1120,10 +1202,21 @@ export function useThreadComposerState() { return; } const thread = selectedThreadDetail ?? selectedThreadShell; - const currentRuntimeMode = selectedDraft?.runtimeMode ?? thread?.runtimeMode; - const currentInteractionMode = selectedDraft?.interactionMode ?? thread?.interactionMode; + if ( + selectedSessionProviderInstanceId !== undefined && + !resolveProviderContinuationTransition({ + providers: selectedThreadServerConfig?.providers ?? [], + currentInstanceId: selectedSessionProviderInstanceId, + targetInstanceId: value.instanceId, + }).compatible + ) { + return; + } + const currentRuntimeMode = composerSettings?.runtimeMode ?? thread?.runtimeMode; + const currentInteractionMode = composerSettings?.interactionMode ?? thread?.interactionMode; updateComposerDraftSettings(selectedThreadKey, { modelSelection: value, + providerSelectionExplicit: true, ...(currentRuntimeMode ? { runtimeMode: resolveModelSelectionRuntimeMode( @@ -1141,8 +1234,9 @@ export function useThreadComposerState() { }); }, [ - selectedDraft?.interactionMode, - selectedDraft?.runtimeMode, + composerSettings?.interactionMode, + composerSettings?.runtimeMode, + selectedSessionProviderInstanceId, selectedThreadDetail, selectedThreadKey, selectedThreadServerConfig, @@ -1170,6 +1264,212 @@ export function useThreadComposerState() { [selectedThreadKey], ); + const onManagePendingSends = useCallback(() => { + const queuedMessage = selectedThreadQueuedMessages[0]; + if (!queuedMessage || !selectedThreadKey) return; + const hold = queuedMessage.deliveryHold; + const boundInstanceId = selectedSessionProviderInstanceId; + const newThreadDestination = + queuedMessage.destination ?? + (selectedThreadShell + ? { + projectId: selectedThreadShell.projectId, + ...(selectedThreadProject?.title === undefined + ? {} + : { projectTitle: selectedThreadProject.title }), + ...(selectedThreadProject?.workspaceRoot === undefined + ? {} + : { projectCwd: selectedThreadProject.workspaceRoot }), + workspaceMode: + selectedThreadShell.worktreePath === null + ? ("local" as const) + : ("worktree" as const), + branch: selectedThreadShell.branch, + worktreePath: null, + } + : null); + const selectedCompatibleSelection = hold + ? resolveHeldSendSelectedProvider({ + boundInstanceId, + selectedModelSelection: composerSettings?.modelSelection, + providers: selectedThreadServerConfig?.providers, + }) + : null; + const freshRetry = (settings?: { + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + }) => { + const metadata = makeQueuedMessageMetadata(); + return retryQueuedThreadMessage(queuedMessage, { + commandId: CommandId.make(metadata.commandId), + createdAt: metadata.createdAt, + ...settings, + }); + }; + const recover = async (input: { + readonly draftKey: string; + readonly startNewThread: boolean; + }) => { + const destination = newThreadDestination; + try { + const result = await recoverPendingSendToComposer({ + message: queuedMessage, + draftKey: input.draftKey, + ...(input.startNewThread && destination + ? { + workspaceSelection: { + mode: destination.workspaceMode, + branch: destination.branch, + worktreePath: destination.worktreePath, + startFromOrigin: destination.startFromOrigin ?? false, + }, + } + : {}), + }); + if (result === "queue-changed") { + Alert.alert( + "Pending send changed", + "Its content was restored, but a newer queued copy appeared and was kept. Review both before sending.", + ); + return; + } + if (input.startNewThread && destination) { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(queuedMessage.environmentId), + projectId: String(destination.projectId), + }, + }); + } + } catch (error) { + Alert.alert( + "Could not restore pending send", + error instanceof Error + ? error.message + : "The pending send remains safely held in the outbox.", + ); + } + }; + const actions: Array<{ + text: string; + style?: "default" | "cancel" | "destructive"; + onPress?: () => void; + }> = []; + + if (hold) { + actions.push({ + text: "Edit pending send", + onPress: () => { + void recover({ draftKey: selectedThreadKey, startNewThread: false }); + }, + }); + if (selectedCompatibleSelection !== null) { + const selectedProviderName = + selectedThreadServerConfig?.providers.find( + (provider) => provider.instanceId === selectedCompatibleSelection.instanceId, + )?.displayName ?? selectedCompatibleSelection.instanceId; + actions.push({ + text: `Use ${selectedProviderName}`, + onPress: () => { + Alert.alert( + `Use ${selectedProviderName}?`, + "This explicitly retargets only this pending send. The provider proves the same continuation identity as the thread binding.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Use selected provider", + onPress: () => { + void updateThreadOutboxMessageIfCurrent( + queuedMessage, + freshRetry({ + modelSelection: selectedCompatibleSelection, + runtimeMode: composerSettings?.runtimeMode, + interactionMode: composerSettings?.interactionMode, + }), + ) + .then((updated) => { + if (!updated) { + Alert.alert( + "Pending send changed", + "A newer queued copy was kept. Open Manage again to review it.", + ); + } + }) + .catch((error: unknown) => { + Alert.alert( + "Could not update pending send", + error instanceof Error ? error.message : "The original hold was kept.", + ); + }); + }, + }, + ], + ); + }, + }); + } + if (newThreadDestination) { + const newThreadDraftKey = `new-task:${scopedProjectKey( + queuedMessage.environmentId, + newThreadDestination.projectId, + )}`; + actions.push({ + text: "Start a new thread", + onPress: () => { + void recover({ draftKey: newThreadDraftKey, startNewThread: true }); + }, + }); + } + } else { + actions.push({ + text: "Retry", + onPress: () => { + void updateThreadOutboxMessageIfCurrent(queuedMessage, freshRetry()); + }, + }); + } + actions.push( + { + text: "Delete pending send", + style: "destructive", + onPress: () => { + void removeThreadOutboxMessageIfCurrent(queuedMessage) + .then((removed) => { + if (!removed) { + Alert.alert( + "Pending send changed", + "A newer queued copy was kept. Open Manage again to delete it explicitly.", + ); + } + }) + .catch((error: unknown) => { + Alert.alert( + "Could not delete pending send", + error instanceof Error ? error.message : "The pending send was kept.", + ); + }); + }, + }, + { text: "Cancel", style: "cancel" }, + ); + Alert.alert( + hold ? "Pending send held" : "Pending send", + hold?.reason ?? "This message is saved on this device and waiting to be sent.", + actions, + ); + }, [ + composerSettings, + navigation, + selectedSessionProviderInstanceId, + selectedThreadKey, + selectedThreadProject, + selectedThreadQueuedMessages, + selectedThreadServerConfig, + selectedThreadShell, + ]); + return { selectedThreadFeed, selectedThreadAgents, @@ -1185,6 +1485,7 @@ export function useThreadComposerState() { ? sessionCompactionPendingAction : null, selectedThreadQueueCount, + selectedThreadQueueHold: selectedThreadQueuedMessages[0]?.deliveryHold ?? null, activeWorkStartedAt, draftMessage, draftAttachments, @@ -1200,6 +1501,7 @@ export function useThreadComposerState() { onRemoveDraftImage, onSendMessage, onQueueFollowUp, + onManagePendingSends, onClearSessionInputQueue, onRemoveOnlySessionInputQueueItem, onSetSessionInputQueueMode, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index b991fa4ee..2e1d5a8d5 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -80,6 +80,42 @@ vi.mock("./entities", () => ({ useThreadShells: () => [], })); +vi.mock("./projects", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentProjects: { + projectsAtom: Atom.make([]).pipe(Atom.keepAlive), + }, + }; +}); + +vi.mock("./presentation", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentPresentations: { + presentationAtom: Atom.family(() => Atom.make(null).pipe(Atom.keepAlive)), + }, + }; +}); + +vi.mock("./shell", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentShell: { + stateValueAtom: Atom.family(() => + Atom.make({ status: "empty" as const }).pipe(Atom.keepAlive), + ), + }, + }; +}); + +vi.mock("./server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive), + }; +}); + vi.mock("./threads", () => ({ threadEnvironment: {}, })); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index c81cb87a8..0b4b4500a 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { - CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -21,7 +20,7 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn" import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { useProjects, useServerConfigs, useThreadShells } from "./entities"; +import { useServerConfigs, useThreadShells } from "./entities"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -32,18 +31,23 @@ import { import { removeThreadOutboxMessage } from "./thread-outbox-removal"; import { isQueuedThreadCreationSendable, - modelSelectionsEqual, + resolveConfirmedThreadOutboxPlan, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, - resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, + threadOutboxDeliveryHoldsEqual, threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadSettingsSnapshot, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; +import { environmentProjects } from "./projects"; +import { environmentPresentations } from "./presentation"; +import { environmentShell } from "./shell"; +import { environmentServerConfigsAtom } from "./server"; import { appendComposerDraftAttachments, composerDraftsAtom, @@ -102,10 +106,6 @@ function findCreationProject( ); } -function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { - return CommandId.make(`${message.commandId}:${setting}`); -} - /** * Uploads a queued message's attachments and persists the uploaded ids back * onto the queued message. The revision-checked update means an edit accepted @@ -486,21 +486,11 @@ async function preserveUploadedAttachmentsForEditor( 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 serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); @@ -590,7 +580,7 @@ export function useThreadOutboxDrain(): void { const reportFailure = ( commandResult: AtomCommandResult, stage: ThreadOutboxCommandStage, - ): { readonly action: "retry" | "restore"; readonly message: string } | null => { + ): { readonly action: "retry" | "hold"; readonly message: string } | null => { if (!AsyncResult.isFailure(commandResult)) { return null; } @@ -610,95 +600,85 @@ export function useThreadOutboxDrain(): void { }); return { action, - message: error instanceof Error ? error.message : "The message could not be sent.", + message: + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "The server rejected this turn before accepting its message. Retry it or resolve the provider binding manually.", }; }; - return { reportFailure }; - }, []); - - const sendQueuedMessage = useCallback( - async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const settings = resolveQueuedThreadSettings(queuedMessage, thread); - const { reportFailure } = 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, + const persistRejectedAdmissionHold = async ( + message: QueuedThreadMessage, + expectedRevision: number, + reason: string, + ): Promise<"held" | "retry" | "complete"> => { + try { + const updated = await updateThreadOutboxMessage( + { + ...message, + deliveryHold: { + kind: "admission-rejected", + reason, + ...(message.modelSelection === undefined + ? {} + : { queuedInstanceId: message.modelSelection.instanceId }), + }, }, + expectedRevision, + ); + // A failed CAS means an edit or removal won. That newer owner decides + // what happens next; do not retry the rejected stale payload. + return updated ? "held" : "complete"; + } catch (error) { + console.warn("[thread-outbox] failed to persist rejected admission hold", { + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + error, }); - if (AsyncResult.isFailure(interactionResult)) { - reportFailure(interactionResult, "settings-sync"); - return false; - } + return "retry"; } + }; + return { persistRejectedAdmissionHold, reportFailure }; + }, []); + const sendQueuedMessage = useCallback( + async ( + queuedMessage: QueuedThreadMessage, + settings: ThreadSettingsSnapshot, + ): Promise<"complete" | "retry" | "held"> => { + const { persistRejectedAdmissionHold, reportFailure } = makeDeliveryHelpers(queuedMessage); let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); if (preparedResult.status === "abandoned") { - return true; + return "complete"; } prepared = preparedResult.prepared; persistedMessage = preparedResult.persistedMessage; deliveryRevision = preparedResult.deliveryRevision; if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - await preserveUploadedAttachmentsForEditor( - queuedMessage, - preparedResult.persistedMessage, - ); - return true; + await preserveUploadedAttachmentsForEditor(queuedMessage, persistedMessage); + return "complete"; } } catch (error) { console.warn("[thread-outbox] failed to upload attachments", error); if (!shouldRetryThreadOutboxDelivery(error)) { - return restoreQueuedMessage( + const restored = await restoreQueuedMessage( queuedMessage, error instanceof Error ? error.message : "An attachment could not upload.", ); + return restored ? "complete" : "retry"; } - return false; + return "retry"; } if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { - return true; + return "complete"; } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -718,33 +698,26 @@ export function useThreadOutboxDrain(): void { }); const failure = reportFailure(deliveryResult, "start-turn"); if (failure?.action === "retry") { - return false; + return "retry"; } - if (failure?.action === "restore") { - return restoreQueuedMessage(persistedMessage, failure.message); + if (failure?.action === "hold") { + return persistRejectedAdmissionHold(persistedMessage, deliveryRevision, failure.message); } + acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); - const delivered = - (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; - if (delivered) { + const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); + if (outcome === "removed") { acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); // The delivered turn holds its own copy of the bytes. A failed delete - // is surfaced (never fails the delivered turn); the server also - // expires leaked pending uploads. + // is surfaced but never fails the delivered turn. await prepared.releaseUploads().catch((error) => { console.warn("[thread-outbox] could not delete consumed pending uploads", error); }); + return "complete"; } - return delivered; + return outcome === "edited" ? "complete" : "retry"; }, - [ - makeDeliveryHelpers, - setThreadInteractionMode, - setThreadRuntimeMode, - startTurn, - updateThreadMetadata, - restoreQueuedMessage, - ], + [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); const sendQueuedCreation = useCallback( @@ -752,42 +725,42 @@ export function useThreadOutboxDrain(): void { queuedMessage: QueuedThreadMessage, creation: QueuedThreadCreation, projectCwd: string, - ) => { + ): Promise<"complete" | "retry" | "held"> => { const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { - return false; + return "retry"; } + const { persistRejectedAdmissionHold, reportFailure } = makeDeliveryHelpers(queuedMessage); let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); if (preparedResult.status === "abandoned") { - return true; + return "complete"; } prepared = preparedResult.prepared; persistedMessage = preparedResult.persistedMessage; deliveryRevision = preparedResult.deliveryRevision; if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - await preserveUploadedAttachmentsForEditor( - queuedMessage, - preparedResult.persistedMessage, - ); - return true; + await preserveUploadedAttachmentsForEditor(queuedMessage, persistedMessage); + return "complete"; } } catch (error) { console.warn("[thread-outbox] failed to upload attachments", error); if (!shouldRetryThreadOutboxDelivery(error)) { - return restoreQueuedMessage( + const restored = await restoreQueuedMessage( queuedMessage, error instanceof Error ? error.message : "An attachment could not upload.", ); + return restored ? "complete" : "retry"; } - return false; + return "retry"; } if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { - return true; + return "complete"; } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -810,33 +783,29 @@ export function useThreadOutboxDrain(): void { worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); - const { reportFailure } = makeDeliveryHelpers(queuedMessage); const failure = reportFailure(deliveryResult, "start-turn"); if (failure?.action === "retry") { - return false; + return "retry"; } - if (failure?.action === "restore") { - return restoreQueuedMessage(persistedMessage, failure.message); + if (failure?.action === "hold") { + return persistRejectedAdmissionHold(persistedMessage, deliveryRevision, failure.message); } + const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); if (outcome === "edited") { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - // The editor holds the entry with unsaved edits; merging the queue - // payload now would duplicate the delivered turn. Once the editor - // saves, the duplicate-creation removal below recovers the edits. - return true; + return "complete"; } - // The thread exists now, so the next drain would remove the edited - // payload as a duplicate creation. Hand it to the thread's composer. - return recoverEditedCreationAfterDelivery(persistedMessage); + const recovered = await recoverEditedCreationAfterDelivery(persistedMessage); + return recovered ? "complete" : "retry"; } if (outcome === "removed") { await prepared.releaseUploads().catch((error) => { console.warn("[thread-outbox] could not delete consumed pending uploads", error); }); - return true; + return "complete"; } - return false; + return "retry"; }, [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); @@ -922,7 +891,8 @@ export function useThreadOutboxDrain(): void { threadExists: thread !== undefined, shellStatus, environmentConnected: environment?.connectionState === "connected", - threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", + threadStatus: thread?.session?.status ?? null, + hasDeliveryHold: nextQueuedMessage.deliveryHold !== undefined, }); // The delivery action resolves first; the file-capability gate applies // only to a message that will send. Gating earlier would restore a @@ -971,96 +941,142 @@ export function useThreadOutboxDrain(): void { .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); return; } - // 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 "complete" as const; } - // The shell state is equally stale. Re-run the same delivery policy - // against the live thread snapshot so a vanished thread or newly - // created target defers, while busy existing threads can still steer. - if (deliveryAction === "send") { - const liveThread = findThread( - appAtomRegistry.get(environmentThreadShells.threadShellsAtom), - nextQueuedMessage, + const latestQueuedMessage = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ) + .flat() + .find( + (candidate) => + candidate.environmentId === nextQueuedMessage.environmentId && + candidate.messageId === nextQueuedMessage.messageId, ); - const liveThreadBusy = - liveThread?.session?.status === "running" || liveThread?.session?.status === "starting"; - const liveDeliveryAction = resolveThreadOutboxDeliveryAction({ - isCreation: creation !== undefined, - threadExists: liveThread !== undefined, - shellStatus, - environmentConnected: environment?.connectionState === "connected", - threadBusy: liveThreadBusy, - }); - if (liveDeliveryAction !== "send") { - return true; + if (latestQueuedMessage === undefined) return "complete" as const; + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[latestQueuedMessage.messageId]) { + return "complete" as const; + } + // Confirmation is an async boundary. Re-read every dispatch authority + // from live atoms instead of using the optimistic render snapshot that + // selected this item before its durable write settled. + const latestThread = findThread( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + latestQueuedMessage, + ); + const latestShellStatus = appAtomRegistry.get( + environmentShell.stateValueAtom(latestQueuedMessage.environmentId), + ).status; + const latestEnvironment = appAtomRegistry.get( + environmentPresentations.presentationAtom(latestQueuedMessage.environmentId), + ); + const latestEnvironmentConnected = latestEnvironment?.connection.phase === "connected"; + const latestServerConfig = appAtomRegistry + .get(environmentServerConfigsAtom) + .get(latestQueuedMessage.environmentId); + const latestProviders = latestServerConfig?.providers; + const latestCreation = latestQueuedMessage.creation; + const latestProject = + latestCreation === undefined + ? null + : findCreationProject( + appAtomRegistry.get(environmentProjects.projectsAtom), + latestQueuedMessage, + ); + const confirmedPlan = resolveConfirmedThreadOutboxPlan({ + message: latestQueuedMessage, + thread: latestThread, + shellStatus: latestShellStatus, + environmentConnected: latestEnvironmentConnected, + providers: latestProviders, + project: latestProject, + }); + const persistHold = ( + hold: NonNullable, + retargetCreation?: QueuedThreadCreation, + ) => { + const alreadyPersisted = + threadOutboxDeliveryHoldsEqual(latestQueuedMessage.deliveryHold, hold) && + (retargetCreation === undefined || latestQueuedMessage.creation === retargetCreation); + if (alreadyPersisted) { + return Promise.resolve("held" as const); } + const revision = threadOutboxRevision(latestQueuedMessage.messageId); + return updateThreadOutboxMessage( + { + ...latestQueuedMessage, + deliveryHold: hold, + ...(retargetCreation === undefined ? {} : { creation: retargetCreation }), + }, + revision, + ).then( + (updated) => (updated ? ("held" as const) : ("complete" as const)), + (error) => { + console.warn("[thread-outbox] failed to persist delivery hold", { + messageId: latestQueuedMessage.messageId, + error, + }); + return "retry" as const; + }, + ); + }; + if (confirmedPlan.action === "wait") { + return latestQueuedMessage.deliveryHold === undefined + ? ("complete" as const) + : ("held" as const); + } + if (confirmedPlan.action === "hold") { + return persistHold(confirmedPlan.hold, confirmedPlan.creation); + } + if (confirmedPlan.action === "remove") { + return recoverEditedCreationAfterDelivery(latestQueuedMessage).then((recovered) => + recovered ? ("complete" as const) : ("retry" as const), + ); + } + + // Apply the current Pylon file-capability gate only after the durable + // confirmation boundary, using the same live config as admission. + const liveDispatchStep = resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: latestQueuedMessage.attachments.filter( + (attachment) => attachment.type === "file", + ), + serverConfig: latestServerConfig + ? { + maxFileUploadBytes: + latestServerConfig.environment.capabilities.fileAttachments?.maxUploadBytes, + } + : null, + }); + if (liveDispatchStep.step === "retry") { + return "retry" as const; + } + if (liveDispatchStep.step === "restore") { + return restoreQueuedMessage(latestQueuedMessage, liveDispatchStep.reason).then( + (restored) => (restored ? ("complete" as const) : ("retry" as const)), + ); + } + if (confirmedPlan.action === "send-existing") { + return sendQueuedMessage(latestQueuedMessage, confirmedPlan.settings); } - return deliveryAction === "remove" - ? creation !== undefined - ? // A creation entry that survived its delivery cleanup either - // holds edits (recover them) or the delivered payload (a - // recovered duplicate the user can delete). Restart loses any - // in-memory distinction, and losing edits is the worse failure, - // so recovery is unconditional here. - recoverEditedCreationAfterDelivery(nextQueuedMessage) - : 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); + return sendQueuedCreation(latestQueuedMessage, latestCreation!, confirmedPlan.projectCwd); }); void delivery - .then((sent) => { - if (sent) { + .then((outcome) => { + if (outcome === "complete" || outcome === "held") { retryAttemptRef.current.delete(nextQueuedMessage.messageId); retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); @@ -1082,7 +1098,6 @@ export function useThreadOutboxDrain(): void { connectedEnvironments, dispatchingQueuedMessageId, editingQueuedMessageIds, - projects, queuedMessagesByThreadKey, retryTick, restoreQueuedMessage, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index c2bb3892d..78adee1f5 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -244,7 +244,7 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); - it("accepts a stale session lifecycle CAS without producing an event", async () => { + it("accepts exact zero-event updates and stale lifecycle CAS without poisoning retries", async () => { const system = await createOrchestrationSystem(); const { engine } = system; const createdAt = now(); @@ -306,6 +306,43 @@ describe("OrchestrationEngine", () => { }), ); const sequenceBeforeStaleLifecycle = await system.run(engine.latestSequence); + const zeroEventCommands = [ + { + type: "thread.meta.update" as const, + commandId: CommandId.make("cmd-session-stale-meta"), + threadId, + modelSelection: { + instanceId: providerInstanceId, + model: "gpt-5-codex", + }, + createdAt, + }, + { + type: "thread.runtime-mode.set" as const, + commandId: CommandId.make("cmd-session-stale-runtime"), + threadId, + runtimeMode: "full-access" as const, + createdAt, + }, + { + type: "thread.interaction-mode.set" as const, + commandId: CommandId.make("cmd-session-stale-interaction"), + threadId, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt, + }, + ]; + for (const command of zeroEventCommands) { + expect(await system.run(engine.dispatch(command))).toEqual({ + sequence: sequenceBeforeStaleLifecycle, + eventCount: 0, + }); + // The accepted zero-event receipt makes exact delivery retries succeed; + // it must never be rewritten as a poisoned rejection. + expect(await system.run(engine.dispatch(command))).toEqual({ + sequence: sequenceBeforeStaleLifecycle, + }); + } const result = await system.run( engine.dispatch({ diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 7b2ba3e13..17f15deda 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -209,6 +209,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { const lastSavedEvent = committedEvents.at(-1) ?? null; const acceptsStaleNoEvent = + envelope.command.type === "thread.meta.update" || + envelope.command.type === "thread.runtime-mode.set" || + envelope.command.type === "thread.interaction-mode.set" || envelope.command.type === "thread.turn.admission.accept" || envelope.command.type === "thread.turn.admission.fail" || envelope.command.type === "thread.session.bind-pending" || diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 57f339b22..224064d50 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1246,6 +1246,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingTurnSessionId: null, activeTurnRequestId: null, failedTurnRequestId: null, + pendingStopRequestId: null, + pendingStopProviderInstanceId: null, + pendingStopSessionIncarnationId: null, + pendingStopTurnRequestId: null, + pendingStopTurnId: null, activeTurnId: null, lastError: null, updatedAt: event.occurredAt, @@ -1313,6 +1318,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti : (incoming.pendingTurnSessionId ?? null), activeTurnRequestId: incoming.activeTurnRequestId ?? null, failedTurnRequestId: incoming.failedTurnRequestId ?? null, + pendingStopRequestId: incoming.pendingStopRequestId ?? null, + pendingStopProviderInstanceId: + incoming.pendingStopRequestId === undefined + ? null + : (incoming.pendingStopProviderInstanceId ?? null), + pendingStopSessionIncarnationId: + incoming.pendingStopRequestId === undefined + ? null + : (incoming.pendingStopSessionIncarnationId ?? null), + pendingStopTurnRequestId: + incoming.pendingStopRequestId === undefined + ? null + : (incoming.pendingStopTurnRequestId ?? null), + pendingStopTurnId: + incoming.pendingStopRequestId === undefined ? null : (incoming.pendingStopTurnId ?? null), activeTurnId: incoming.activeTurnId, lastError: incoming.lastError, updatedAt: incoming.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5a98cb093..6bdbc7c30 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -348,6 +348,15 @@ function mapSessionRow( : {}), ...(row.activeTurnRequestId !== null ? { activeTurnRequestId: row.activeTurnRequestId } : {}), ...(row.failedTurnRequestId !== null ? { failedTurnRequestId: row.failedTurnRequestId } : {}), + ...(row.pendingStopRequestId !== null + ? { + pendingStopRequestId: row.pendingStopRequestId, + pendingStopProviderInstanceId: row.pendingStopProviderInstanceId, + pendingStopSessionIncarnationId: row.pendingStopSessionIncarnationId, + pendingStopTurnRequestId: row.pendingStopTurnRequestId, + pendingStopTurnId: row.pendingStopTurnId, + } + : {}), activeTurnId: row.activeTurnId, lastError: row.lastError, updatedAt: row.updatedAt, @@ -661,6 +670,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_turn_session_id AS "pendingTurnSessionId", active_turn_request_id AS "activeTurnRequestId", failed_turn_request_id AS "failedTurnRequestId", + pending_stop_request_id AS "pendingStopRequestId", + pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + pending_stop_turn_request_id AS "pendingStopTurnRequestId", + pending_stop_turn_id AS "pendingStopTurnId", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" @@ -694,6 +708,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessions.pending_turn_session_id AS "pendingTurnSessionId", sessions.active_turn_request_id AS "activeTurnRequestId", sessions.failed_turn_request_id AS "failedTurnRequestId", + sessions.pending_stop_request_id AS "pendingStopRequestId", + sessions.pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + sessions.pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + sessions.pending_stop_turn_request_id AS "pendingStopTurnRequestId", + sessions.pending_stop_turn_id AS "pendingStopTurnId", sessions.active_turn_id AS "activeTurnId", sessions.last_error AS "lastError", sessions.updated_at AS "updatedAt" @@ -731,6 +750,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessions.pending_turn_session_id AS "pendingTurnSessionId", sessions.active_turn_request_id AS "activeTurnRequestId", sessions.failed_turn_request_id AS "failedTurnRequestId", + sessions.pending_stop_request_id AS "pendingStopRequestId", + sessions.pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + sessions.pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + sessions.pending_stop_turn_request_id AS "pendingStopTurnRequestId", + sessions.pending_stop_turn_id AS "pendingStopTurnId", sessions.active_turn_id AS "activeTurnId", sessions.last_error AS "lastError", sessions.updated_at AS "updatedAt" @@ -1184,6 +1208,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_turn_session_id AS "pendingTurnSessionId", active_turn_request_id AS "activeTurnRequestId", failed_turn_request_id AS "failedTurnRequestId", + pending_stop_request_id AS "pendingStopRequestId", + pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + pending_stop_turn_request_id AS "pendingStopTurnRequestId", + pending_stop_turn_id AS "pendingStopTurnId", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" @@ -1834,6 +1863,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ...(row.failedTurnRequestId !== null ? { failedTurnRequestId: row.failedTurnRequestId } : {}), + ...(row.pendingStopRequestId !== null + ? { + pendingStopRequestId: row.pendingStopRequestId, + pendingStopProviderInstanceId: row.pendingStopProviderInstanceId, + pendingStopSessionIncarnationId: row.pendingStopSessionIncarnationId, + pendingStopTurnRequestId: row.pendingStopTurnRequestId, + pendingStopTurnId: row.pendingStopTurnId, + } + : {}), activeTurnId: row.activeTurnId, lastError: row.lastError, updatedAt: row.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9c72fb712..7b8046f4d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -54,6 +54,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import type { OrchestrationDispatchError } from "../Errors.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { @@ -171,6 +172,7 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly unavailableProviderReason?: string; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; readonly interruptTurnEffect?: () => Effect.Effect; @@ -184,7 +186,13 @@ describe("ProviderCommandReactor", () => { >; readonly publishTurnStartedSynchronously?: boolean; readonly beforeAdmissionFailureDispatch?: Effect.Effect; - readonly beforeReactorStart?: Effect.Effect; + readonly beforeAdmissionBindDispatch?: Effect.Effect; + readonly beforeReactorStart?: Effect.Effect< + void, + OrchestrationDispatchError, + OrchestrationEngineService + >; + readonly initialRuntimeSessions?: ReadonlyArray; readonly clock?: Clock.Clock; readonly overdueTurnStartBeforeReactor?: { readonly commandId: CommandId; @@ -195,6 +203,10 @@ describe("ProviderCommandReactor", () => { readonly inventoryEffect?: ( instanceId: ProviderInstanceId, ) => Effect.Effect, ProviderAdapterRequestError>; + readonly sessionContinuation?: { + readonly providerInstanceId: ProviderInstanceId; + readonly resumeCursor: unknown; + } | null; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -207,8 +219,8 @@ describe("ProviderCommandReactor", () => { turnInput: ProviderSendTurnInput, turnId: TurnId, ) => Effect.Effect = () => Effect.void; - let nextSessionIndex = 1; - const runtimeSessions: Array = []; + const runtimeSessions: Array = [...(input?.initialRuntimeSessions ?? [])]; + let nextSessionIndex = runtimeSessions.length + 1; const modelSelection = input?.threadModelSelection ?? { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -388,6 +400,13 @@ describe("ProviderCommandReactor", () => { ...(input?.requiresNewThreadForModelChange === true ? { requiresNewThreadForModelChange: true } : {}), + ...(input?.unavailableProviderReason === undefined + ? {} + : { + availability: "unavailable" as const, + unavailableReason: input.unavailableProviderReason, + enabled: false, + }), }, ]; @@ -426,12 +445,25 @@ describe("ProviderCommandReactor", () => { refineSessionHarness: () => unsupported(), stopSession: stopSession as ProviderServiceShape["stopSession"], listSessions: () => Effect.succeed(runtimeSessions), + getSessionContinuation: () => Effect.succeed(input?.sessionContinuation ?? null), listSessionsForInstance, getCapabilities: (_provider) => Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", }), getInstanceInfo: (instanceId) => { + if ( + input?.unavailableProviderReason !== undefined && + instanceId === modelSelection.instanceId + ) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: String(instanceId), + method: "ProviderService.getInstanceInfo", + detail: "Provider instance is not materialized.", + }), + ); + } const raw = String(instanceId); const driverKind = ProviderDriverKind.make( raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, @@ -489,6 +521,14 @@ describe("ProviderCommandReactor", () => { Effect.andThen(engine.dispatch(command)), ); } + if ( + command.type === "thread.session.bind-pending" && + input?.beforeAdmissionBindDispatch !== undefined + ) { + return input.beforeAdmissionBindDispatch.pipe( + Effect.andThen(engine.dispatch(command)), + ); + } if (command.type === "thread.title.regeneration.complete") { titleRegenerationCompletionDispatchAttempts += 1; if ( @@ -722,12 +762,72 @@ describe("ProviderCommandReactor", () => { stateDir, drain, runEffect, + getAdmissionTrackingCounts: reactor.getAdmissionTrackingCounts!, get titleRegenerationCompletionDispatchAttempts() { return titleRegenerationCompletionDispatchAttempts; }, }; } + it("reclaims admission lanes after long historical-thread churn", async () => { + const harness = await createHarness(); + expect(harness.getAdmissionTrackingCounts()).toEqual({ + fibers: 0, + fiberThreads: 0, + stopTokens: 0, + permits: 0, + }); + + for (let index = 0; index < 100; index += 1) { + const threadId = ThreadId.make(`thread-admission-churn-${index}`); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-thread-admission-churn-create-${index}`), + threadId, + projectId: asProjectId("project-1"), + title: `Churn ${index}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-thread-admission-churn-start-${index}`), + threadId, + message: { + messageId: asMessageId(`message-admission-churn-${index}`), + role: "user", + text: `churn ${index}`, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + } + + await waitFor(() => harness.sendTurn.mock.calls.length === 100); + await waitFor(() => + Object.values(harness.getAdmissionTrackingCounts()).every((count) => count === 0), + ); + expect(harness.getAdmissionTrackingCounts()).toEqual({ + fibers: 0, + fiberThreads: 0, + stopTokens: 0, + permits: 0, + }); + }); + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -768,6 +868,51 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("surfaces exact unavailable remediation before starting a provider runtime", async () => { + const reason = + "Prime Agent requires WSL2 on native Windows. Connect to a supported environment."; + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "default", + }, + unavailableProviderReason: reason, + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unavailable-prime"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unavailable-prime"), + role: "user", + text: "must not start", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + activity.payload.detail === reason, + ) ?? false + ); + }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); @@ -806,6 +951,153 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("stops a slow first-turn admission before it can bind or send", () => + Effect.gen(function* () { + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + Deferred.succeed(startEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseStart)), + Effect.as(session), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-stop-slow-first-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-stop-slow-first-turn"), + role: "user", + text: "do not send after stop", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Deferred.await(startEntered); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-slow-first-turn-stop"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + yield* Effect.promise(() => harness.drain()); + let thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("stopped"); + + yield* Deferred.succeed(releaseStart, undefined); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("stopped"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("quarantines the exact runtime when the admission bind CAS loses", () => + Effect.gen(function* () { + const bindEntered = yield* Deferred.make(); + const releaseBind = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + beforeAdmissionBindDispatch: Deferred.succeed(bindEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseBind)), + ), + }), + ); + const threadId = ThreadId.make("thread-1"); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bind-cas-loss-start"), + threadId, + message: { + messageId: asMessageId("message-bind-cas-loss"), + role: "user", + text: "lose the bind", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(bindEntered); + expect(harness.runtimeSessions).toHaveLength(1); + const createdRuntime = harness.runtimeSessions[0]; + + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-bind-cas-loss-stop"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.succeed(releaseBind, undefined); + yield* Effect.promise(() => + waitFor(() => + harness.stopSession.mock.calls.some( + ([input]) => + typeof input === "object" && + input !== null && + "expectedSessionIncarnationId" in input && + input.expectedSessionIncarnationId === createdRuntime?.sessionIncarnationId, + ), + ), + ); + yield* Effect.promise(() => harness.drain()); + + expect(harness.runtimeSessions).toHaveLength(0); + expect(harness.sendTurn).not.toHaveBeenCalled(); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("stopped"); + expect( + thread?.activities.filter((activity) => activity.kind === "provider.turn.start.failed"), + ).toHaveLength(0); + }), + ); + + effectIt.effect("cancels first-turn admission when Stop lands before provider start runs", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-stop-before-provider-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-stop-before-provider-start"), + role: "user", + text: "cancel before provider start", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-before-provider-start-stop"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("stopped"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + effectIt.effect("times out a hung provider admission with a correlated failure", () => Effect.gen(function* () { const testClock = yield* TestClock.make(); @@ -1170,7 +1462,7 @@ describe("ProviderCommandReactor", () => { ); effectIt.effect( - "preserves a pending admission when runtime mode changes during provider start", + "keeps accepted settings authoritative when stale metadata changes during provider start", () => Effect.gen(function* () { const startEntered = yield* Deferred.make(); @@ -1238,7 +1530,8 @@ describe("ProviderCommandReactor", () => { readModel = yield* Effect.promise(() => harness.readModel()); thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.runtimeMode).toBe("full-access"); + expect(thread?.runtimeMode).toBe("approval-required"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); expect(thread?.session?.status).toBe("running"); expect(thread?.session?.activeTurnRequestId).toBe(requestId); expect(harness.startSession).toHaveBeenCalledTimes(1); @@ -2849,6 +3142,15 @@ describe("ProviderCommandReactor", () => { }); yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + const beforeRejectedChange = (yield* Effect.promise(() => + harness.readModel(), + )).threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const beforeProjectedSettings = { + modelSelection: beforeRejectedChange?.modelSelection, + runtimeMode: beforeRejectedChange?.runtimeMode, + interactionMode: beforeRejectedChange?.interactionMode, + providerInstanceId: beforeRejectedChange?.session?.providerInstanceId, + }; yield* harness.engine.dispatch({ type: "thread.turn.start", @@ -2886,6 +3188,12 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn).toHaveBeenCalledTimes(1); const readModel = yield* Effect.promise(() => harness.readModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect({ + modelSelection: thread?.modelSelection, + runtimeMode: thread?.runtimeMode, + interactionMode: thread?.interactionMode, + providerInstanceId: thread?.session?.providerInstanceId, + }).toEqual(beforeProjectedSettings); expect( thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), ).toMatchObject({ @@ -2992,7 +3300,85 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); - it("restarts an existing Codex thread on a compatible requested instance", async () => { + effectIt.effect("delivers an accepted steer before a later settings transition", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-admission-order-initial"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-admission-order-initial"), + role: "user", + text: "initial", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "running"; + }), + ); + + const steerEntered = yield* Deferred.make(); + const releaseSteer = yield* Deferred.make(); + harness.sendTurn.mockImplementationOnce((input: ProviderSendTurnInput) => + Deferred.succeed(steerEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseSteer)), + Effect.as({ threadId: input.threadId, turnId: asTurnId("turn-steer-a") }), + ), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-admission-order-steer-a"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-admission-order-steer-a"), + role: "user", + text: "steer A", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Deferred.await(steerEntered); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-admission-order-settings-b"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-admission-order-settings-b"), + role: "user", + text: "settings B", + attachments: [], + }, + interactionMode: "plan", + runtimeMode: "full-access", + createdAt: now, + }); + yield* Effect.yieldNow; + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.sendTurn).toHaveBeenCalledTimes(2); + + yield* Deferred.succeed(releaseSteer, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 3)); + expect(harness.startSession).toHaveBeenCalledTimes(2); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ input: "steer A" }); + expect(harness.sendTurn.mock.calls[2]?.[0]).toMatchObject({ input: "settings B" }); + }), + ); + + it("accepts a compatible cross-account Codex switch and projects it atomically", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -3016,8 +3402,32 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "running"; + }); + const afterFirst = (await harness.readModel()).threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), + ); + expect(afterFirst?.session?.status).toBe("running"); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-compatible-codex-ready"), + threadId: ThreadId.make("thread-1"), + session: { + ...afterFirst!.session!, + status: "ready", + activeTurnRequestId: undefined, + activeTurnId: null, + updatedAt: now, + }, + createdAt: now, + }), + ); await Effect.runPromise( harness.engine.dispatch({ @@ -3034,24 +3444,90 @@ describe("ProviderCommandReactor", () => { instanceId: ProviderInstanceId.make("codex_work"), model: "gpt-5-codex", }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:00.000Z", + interactionMode: "plan", + runtimeMode: "full-access", + createdAt: now, }), ); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - + const switched = (await harness.readModel()).threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), + ); expect(harness.startSession).toHaveBeenCalledTimes(2); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - provider: ProviderDriverKind.make("codex"), providerInstanceId: ProviderInstanceId.make("codex_work"), - resumeCursor: { opaque: "resume-1" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + }); + expect(switched?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + expect(switched?.modelSelection).toEqual({ + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }); + expect(switched?.runtimeMode).toBe("full-access"); + expect(switched?.interactionMode).toBe("plan"); + }); + + it("passes the exact persisted cursor through a cold compatible Codex switch", async () => { + const persistedCursor = { threadId: "codex-thread-persisted", rollout: "rollout-42" }; + const harness = await createHarness({ + sessionContinuation: { + providerInstanceId: ProviderInstanceId.make("codex"), + resumeCursor: persistedCursor, + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-cold-compatible-codex-stopped"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "stopped", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + sessionIncarnationId: RuntimeSessionId.make("session-cold-codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-cold-compatible-codex"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-cold-compatible-codex"), + role: "user", + text: "continue the exact conversation", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + resumeCursor: persistedCursor, }); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); }); it("restarts the provider session when the thread workspace changes", async () => { @@ -3229,22 +3705,6 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-1"), - threadId: ThreadId.make("thread-1"), - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return thread?.runtimeMode === "approval-required"; - }); - await waitFor(() => harness.startSession.mock.calls.length === 2); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -3257,11 +3717,12 @@ describe("ProviderCommandReactor", () => { attachments: [], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", + runtimeMode: "approval-required", createdAt: now, }), ); + await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.stopSession.mock.calls.length).toBe(0); @@ -3328,7 +3789,7 @@ describe("ProviderCommandReactor", () => { }); }); - it("does not stop the active session when restart fails before rebind", async () => { + it("does not project changed settings when provider restart fails before rebind", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -3368,35 +3829,44 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-restart-failure-2"), threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-restart-failure-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); + await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(async () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return thread?.runtimeMode === "approval-required"; + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); }); - await waitFor(() => harness.startSession.mock.calls.length === 2); - await harness.drain(); expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.sendTurn.mock.calls.length).toBe(1); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.runtimeMode).toBe("full-access"); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("full-access"); }); - it("rejects provider changes after a thread is already bound to a session provider", async () => { + it("rejects provider changes atomically after a thread is bound", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -3413,8 +3883,6 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - - await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); await Effect.runPromise( @@ -3439,36 +3907,33 @@ describe("ProviderCommandReactor", () => { ); await waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + String(activity.payload.detail).includes("cannot switch to 'claudeAgent'"), + ) ?? false ); }); - - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls.length).toBe(1); - expect(harness.stopSession.mock.calls.length).toBe(0); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.providerName).toBe("codex"); - expect(thread?.session?.runtimeMode).toBe("approval-required"); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.modelSelection.instanceId).toBe(ProviderInstanceId.make("codex")); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex")); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.stopSession).not.toHaveBeenCalled(); }); - it("rejects cross-driver provider changes after the existing thread session has stopped", async () => { + it("rejects cross-driver changes after the existing session stops", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", @@ -3508,27 +3973,23 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await waitFor(async () => { - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + String(activity.payload.detail).includes("cannot switch to 'claudeAgent'"), + ) ?? false ); }); - - expect(harness.startSession.mock.calls.length).toBe(0); - expect(harness.sendTurn.mock.calls.length).toBe(0); - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); }); it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { @@ -3836,6 +4297,68 @@ describe("ProviderCommandReactor", () => { }); }); + it("blocks a stale projected model instead of synthesizing it onto the Prime binding", async () => { + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-projected-prime"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "primeAgent", + providerInstanceId: ProviderInstanceId.make("primeAgent"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-projected-prime"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-projected-prime"), + role: "user", + text: "continue the Prime session", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + typeof activity.payload === "object" && + activity.payload !== null && + "detail" in activity.payload && + String(activity.payload.detail).includes("cannot switch to 'codex'"), + ) ?? false + ); + }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }); + it("rejects active runtime sessions that are missing provider instance ids", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -4006,7 +4529,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval-error"), @@ -4024,7 +4547,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-approval-requested"), @@ -4045,7 +4568,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond-stale"), @@ -4197,6 +4720,278 @@ describe("ProviderCommandReactor", () => { expect(resolvedActivity).toBeUndefined(); }); + const makeRestartStopTarget = (): ProviderSession => ({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + model: "gpt-5-codex", + sessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + const seedPendingStopBeforeReactor = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-restart-stop-bind"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + startedAt: "2026-01-01T00:00:00.000Z", + sessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + activeTurnRequestId: CommandId.make("cmd-restart-stop-turn"), + activeTurnId: TurnId.make("turn-restart-stop"), + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-restart-stop"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + }); + }); + + it.each([true, false])( + "reconciles a durable pending stop on restart when the exact runtime is present=%s", + async (runtimePresent) => { + const harness = await createHarness({ + beforeReactorStart: seedPendingStopBeforeReactor, + initialRuntimeSessions: runtimePresent ? [makeRestartStopTarget()] : [], + }); + + expect(harness.stopSession).toHaveBeenCalledTimes(1); + expect(harness.stopSession).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + expectedProviderInstanceId: ProviderInstanceId.make("codex"), + expectedSessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + expectedAdmissionRequestId: CommandId.make("cmd-restart-stop-turn"), + invalidateStartReservation: true, + }); + expect(harness.runtimeSessions).toHaveLength(0); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.session?.status === "stopped" && thread.session.pendingStopRequestId === undefined + ); + }); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + }); + expect(thread?.session?.activeTurnRequestId).toBeUndefined(); + }, + ); + + it("clears only the old pending stop when a later Start was persisted before restart", async () => { + const newRequestId = CommandId.make("cmd-restart-stop-later-start"); + const newMessageId = MessageId.make("message-restart-stop-later-start"); + const harness = await createHarness({ + initialRuntimeSessions: [makeRestartStopTarget()], + beforeReactorStart: seedPendingStopBeforeReactor.pipe( + Effect.andThen( + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: newRequestId, + threadId: ThreadId.make("thread-1"), + message: { + messageId: newMessageId, + role: "user", + text: "start after durable stop", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:02.000Z", + }); + }), + ), + ), + }); + + expect(harness.stopSession).toHaveBeenCalledWith( + expect.objectContaining({ + expectedSessionIncarnationId: RuntimeSessionId.make("session-restart-stop"), + invalidateStartReservation: false, + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.session?.status === "starting" && + thread.session.pendingTurnRequestId === newRequestId && + thread.session.pendingStopRequestId === undefined + ); + }); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.messages.map((message) => message.id)).toContain(newMessageId); + expect(thread?.session?.pendingTurnRequestId).toBe(newRequestId); + expect(thread?.session?.pendingStopRequestId).toBeUndefined(); + }); + + effectIt.effect( + "keeps a post-receipt Stop → Start admission authoritative for plain and settings starts", + () => + Effect.gen(function* () { + const stopEntered = yield* Deferred.make(); + const releaseFirstStop = yield* Deferred.make(); + let stopCall = 0; + const harness = yield* Effect.promise(() => + createHarness({ + stopSessionEffect: () => { + stopCall += 1; + return stopCall === 1 + ? Deferred.succeed(stopEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstStop)), + ) + : Effect.void; + }, + }), + ); + const threadId = ThreadId.make("thread-1"); + const start = ( + suffix: string, + settings?: { + readonly modelSelection: ModelSelection; + readonly runtimeMode: "full-access"; + }, + ) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-stop-start-${suffix}`), + threadId, + message: { + messageId: asMessageId(`message-stop-start-${suffix}`), + role: "user", + text: suffix, + attachments: [], + }, + ...(settings?.modelSelection === undefined + ? {} + : { modelSelection: settings.modelSelection }), + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: settings?.runtimeMode ?? "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* start("initial"); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return thread?.session?.status === "running"; + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-start-stop-first"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.await(stopEntered); + yield* Effect.promise(() => + waitFor(async () => { + const snapshot = await harness.readModel(); + return snapshot.threads.some( + (thread) => + thread.id === threadId && + thread.session?.pendingStopRequestId === + CommandId.make("cmd-stop-start-stop-first"), + ); + }), + ); + let thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("stopped"); + + const plainRequestId = CommandId.make("cmd-stop-start-plain"); + yield* start("plain"); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.pendingTurnRequestId).toBe(plainRequestId); + expect(thread?.session?.pendingStopRequestId).toBe( + CommandId.make("cmd-stop-start-stop-first"), + ); + + yield* Deferred.succeed(releaseFirstStop, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 2)); + yield* Effect.promise(() => + waitFor(async () => { + const current = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return ( + current?.session?.status === "running" && + current.session.activeTurnRequestId === plainRequestId + ); + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-start-stop-second"), + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + }); + yield* Effect.promise(() => + waitFor(async () => { + const current = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return current?.session?.status === "stopped"; + }), + ); + + const settingsRequestId = CommandId.make("cmd-stop-start-settings"); + yield* start("settings", { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + }); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 3)); + yield* Effect.promise(() => + waitFor(async () => { + const current = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return ( + current?.session?.status === "running" && + current.session.activeTurnRequestId === settingsRequestId && + current.runtimeMode === "full-access" + ); + }), + ); + }), + ); + it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index dd3bcd9ea..935437d94 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,7 +11,6 @@ import { type OrchestrationSession, ThreadId, type ProviderSession, - type RuntimeMode, type TurnId, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; @@ -31,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -41,6 +41,10 @@ import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; +import { + findUnavailableProviderInstance, + providerUnavailableDetail, +} from "../../provider/providerUnavailable.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -58,6 +62,10 @@ import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); +type TurnAdmissionIntent = NonNullable< + Extract["payload"]["admissionIntent"] +>; + type ProviderIntentEvent = Extract< OrchestrationEvent, { @@ -117,7 +125,6 @@ export const PROVIDER_TURN_INVENTORY_ATTEMPT_TIMEOUT_MS = 2_000; export const PROVIDER_TURN_INVENTORY_RETRY_TIMEOUT_MS = 6_500; const PROVIDER_TURN_ADMISSION_TIMEOUT_DETAIL = "Provider did not start the requested turn within 60 seconds."; -const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; @@ -356,6 +363,32 @@ const make = Effect.gen(function* () { const threadModelSelections = new Map(); const admissionFibers = new Map>(); + const admissionFiberThreads = new Map(); + type AdmissionStopToken = object; + const admissionStopTokens = new Map(); + const admissionPermits = new Map(); + const makeAdmissionStopToken = (): AdmissionStopToken => Object.freeze({}); + const admissionStopTokenForRequest = (requestId: CommandId) => { + const current = admissionStopTokens.get(requestId); + if (current !== undefined) return current; + const created = makeAdmissionStopToken(); + admissionStopTokens.set(requestId, created); + return created; + }; + const admissionPermitForThread = (threadId: ThreadId) => { + const current = admissionPermits.get(threadId); + if (current !== undefined) return current; + const created = Semaphore.makeUnsafe(1); + admissionPermits.set(threadId, created); + return created; + }; + const releaseAdmissionLaneIfIdle = (threadId: ThreadId) => { + for (const fiberThreadId of admissionFiberThreads.values()) { + if (fiberThreadId === threadId) return; + } + admissionPermits.delete(threadId); + }; + yield* Effect.addFinalizer(() => Effect.gen(function* () { const fibers = Array.from(admissionFibers.entries()); @@ -372,7 +405,11 @@ const make = Effect.gen(function* () { if (Option.isSome(joined)) { for (const [requestId, fiber] of fibers) { if (admissionFibers.get(requestId) === fiber && fiber.pollUnsafe() !== undefined) { + const threadId = admissionFiberThreads.get(requestId); admissionFibers.delete(requestId); + admissionFiberThreads.delete(requestId); + admissionStopTokens.delete(requestId); + if (threadId !== undefined) releaseAdmissionLaneIfIdle(threadId); } } } else if (fibers.length > 0) { @@ -461,6 +498,9 @@ const make = Effect.gen(function* () { expectedActiveTurnRequestId: expected?.activeTurnRequestId ?? null, expectedActiveTurnId: expected?.activeTurnId ?? null, expectedFailedTurnRequestId: expected?.failedTurnRequestId ?? null, + expectedPendingStopRequestId: expected?.pendingStopRequestId ?? null, + expectedPendingStopSessionIncarnationId: + expected?.pendingStopSessionIncarnationId ?? null, ...(input.allowFailedTurnRequestClear === true ? { allowFailedTurnRequestClear: true as const } : {}), @@ -609,11 +649,15 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; + readonly runtimeMode?: OrchestrationSession["runtimeMode"]; + readonly interactionMode?: "default" | "plan"; readonly pendingTurnStart?: boolean; readonly pendingTurnRequestId?: CommandId; readonly pendingTurnMessageId?: MessageId; readonly pendingTurnRequestedAt?: string; readonly pendingTurnDeadlineAt?: string; + readonly expectedProviderInstanceId?: ProviderInstanceId | null; + readonly expectedSessionIncarnationId?: TurnAdmissionIntent["expectedSessionIncarnationId"]; }, ) { const thread = yield* resolveThread(threadId); @@ -621,7 +665,8 @@ const make = Effect.gen(function* () { return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); } - const desiredRuntimeMode = thread.runtimeMode; + const desiredRuntimeMode = options?.runtimeMode ?? thread.runtimeMode; + const desiredInteractionMode = options?.interactionMode ?? thread.interactionMode; const requestedModelSelection = options?.modelSelection; const resolveActiveSession = (threadId: ThreadId) => providerService @@ -629,9 +674,31 @@ const make = Effect.gen(function* () { .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); const activeSession = yield* resolveActiveSession(threadId); - const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; - const desiredInstanceId = desiredModelSelection.instanceId; - + const observedInstanceId = thread.session?.providerInstanceId; + const persistedInstanceId = + options?.expectedProviderInstanceId === undefined + ? observedInstanceId + : (options.expectedProviderInstanceId ?? undefined); + if ( + options?.expectedProviderInstanceId !== undefined && + (observedInstanceId ?? null) !== options.expectedProviderInstanceId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(options.expectedProviderInstanceId ?? "unknown")), + method: "thread.turn.start", + detail: `Turn admission observed a different provider binding for thread '${threadId}'.`, + }); + } + if ( + options?.expectedSessionIncarnationId !== undefined && + (thread.session?.sessionIncarnationId ?? null) !== options.expectedSessionIncarnationId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(persistedInstanceId ?? "unknown")), + method: "thread.turn.start", + detail: `Turn admission observed a different provider session incarnation for thread '${threadId}'.`, + }); + } const activeThreadSession = thread.session !== null && thread.session.status !== "stopped" && activeSession ? thread.session @@ -648,91 +715,128 @@ const make = Effect.gen(function* () { detail: `Thread '${threadId}' has an active provider session without a provider instance id.`, }); } - const currentInstanceId = - activeThreadSession !== null && + if ( + persistedInstanceId !== undefined && + activeSession?.providerInstanceId !== undefined && + activeSession.providerInstanceId !== persistedInstanceId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(persistedInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' is bound to provider instance '${persistedInstanceId}', but the active runtime belongs to '${activeSession.providerInstanceId}'.`, + }); + } + + if ( + options?.expectedSessionIncarnationId !== undefined && activeSession !== undefined && - activeSession.providerInstanceId !== undefined - ? activeSession.providerInstanceId - : thread.modelSelection.instanceId; - const currentInfo = yield* providerService.getInstanceInfo(currentInstanceId).pipe( - Effect.mapError( - () => - new ProviderAdapterRequestError({ - provider: providerErrorLabelFromInstanceHint({ - instanceId: String(currentInstanceId), - modelSelectionInstanceId: String(thread.modelSelection.instanceId), - sessionProvider: thread.session?.providerName ?? undefined, - }), - method: "thread.turn.start", - detail: `Thread '${threadId}' references unknown provider instance '${currentInstanceId}'. The instance is not configured in this build.`, - }), - ), - ); - const desiredInfo = yield* providerService.getInstanceInfo(desiredInstanceId).pipe( - Effect.mapError( - () => - new ProviderAdapterRequestError({ - provider: providerErrorLabelFromInstanceHint({ - instanceId: String(desiredModelSelection.instanceId), - }), - method: "thread.turn.start", - detail: `Requested provider instance '${desiredInstanceId}' is not configured in this build.`, - }), - ), - ); - const desiredDriverKind = desiredInfo.driverKind; - if (!isProviderDriverKind(desiredDriverKind)) { + activeSession.sessionIncarnationId !== options.expectedSessionIncarnationId + ) { return yield* new ProviderAdapterRequestError({ - provider: providerErrorLabel(String(desiredDriverKind)), + provider: providerErrorLabel(String(persistedInstanceId ?? "unknown")), method: "thread.turn.start", - detail: `Requested provider instance '${desiredInstanceId}' uses unknown provider driver '${desiredDriverKind}'. The driver is not installed in this build.`, + detail: `The provider runtime for thread '${threadId}' no longer matches the accepted session incarnation.`, }); } - const preferredProvider: ProviderDriverKind = desiredDriverKind; + + // The projected session binding survives provider-runtime restarts. Keep + // resolving that exact instance when the in-memory session is gone. A model + // selection from another instance is not repairable by replacing only its + // routing key: its model and options belong to the other provider. + const currentInstanceId = + persistedInstanceId ?? activeSession?.providerInstanceId ?? thread.modelSelection.instanceId; + const cachedModelSelection = threadModelSelections.get(threadId); + const persistedModelSelection = + cachedModelSelection?.instanceId === currentInstanceId + ? cachedModelSelection + : activeSession?.providerInstanceId === currentInstanceId && activeSession.model + ? { instanceId: currentInstanceId, model: activeSession.model } + : thread.modelSelection.instanceId === currentInstanceId + ? thread.modelSelection + : undefined; const hasStartedSession = activeSession !== undefined || thread.latestTurn !== null || thread.session?.startedAt !== undefined || - (thread.session?.providerInstanceId !== undefined && - thread.session.providerInstanceId !== desiredInstanceId); - if (thread.session !== null && hasStartedSession) { - yield* rejectStartedThreadModelChangeIfRequired({ - threadId, - currentModelSelection: - activeSession?.model !== undefined - ? { - ...thread.modelSelection, - instanceId: currentInstanceId, - model: activeSession.model, - } - : thread.modelSelection, - requestedModelSelection, + thread.session?.providerInstanceId !== undefined; + const desiredModelSelection = requestedModelSelection ?? persistedModelSelection; + if (desiredModelSelection === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(currentInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' is bound to provider instance '${currentInstanceId}', but no model selection persisted for that exact instance. Start a new thread or explicitly select a model for the bound provider.`, }); } - if ( - thread.session !== null && - hasStartedSession && - requestedModelSelection !== undefined && - requestedModelSelection.instanceId !== currentInstanceId + const desiredInstanceId = desiredModelSelection.instanceId; + const resolveInstanceInfo = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + role: "current" | "requested", ) { + return yield* providerService.getInstanceInfo(instanceId).pipe( + Effect.catch(() => + providerRegistry.getProviders.pipe( + Effect.flatMap((providers) => { + const unavailable = findUnavailableProviderInstance(providers, instanceId); + return Effect.fail( + new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(instanceId), + modelSelectionInstanceId: String(thread.modelSelection.instanceId), + sessionProvider: thread.session?.providerName ?? undefined, + }), + method: "thread.turn.start", + detail: unavailable + ? providerUnavailableDetail(unavailable) + : role === "current" + ? `Thread '${threadId}' references unknown provider instance '${instanceId}'. The instance is not configured in this build.` + : `Requested provider instance '${instanceId}' is not configured in this build.`, + }), + ); + }), + ), + ), + ); + }); + const desiredInfo = yield* resolveInstanceInfo(desiredInstanceId, "requested"); + if (hasStartedSession && desiredInstanceId !== currentInstanceId) { + const currentInfo = yield* resolveInstanceInfo(currentInstanceId, "current"); + const currentContinuationKey = currentInfo.continuationIdentity.continuationKey.trim(); + const desiredContinuationKey = desiredInfo.continuationIdentity.continuationKey.trim(); if (currentInfo.driverKind !== desiredInfo.driverKind) { return yield* new ProviderAdapterRequestError({ - provider: preferredProvider, + provider: providerErrorLabel(String(desiredInfo.driverKind)), method: "thread.turn.start", - detail: `Thread '${threadId}' is bound to driver '${currentInfo.driverKind}' and cannot switch to '${desiredInfo.driverKind}'.`, + detail: `Thread '${threadId}' is bound to driver '${currentInfo.driverKind}' and cannot switch to '${desiredInfo.driverKind}'. Start a new thread to change providers.`, }); } if ( - currentInfo.continuationIdentity.continuationKey !== - desiredInfo.continuationIdentity.continuationKey + currentContinuationKey.length === 0 || + desiredContinuationKey.length === 0 || + currentContinuationKey !== desiredContinuationKey ) { return yield* new ProviderAdapterRequestError({ - provider: preferredProvider, + provider: providerErrorLabel(String(desiredInfo.driverKind)), method: "thread.turn.start", - detail: `Thread '${threadId}' cannot switch from instance '${currentInstanceId}' to '${desiredInstanceId}' because their provider resume state is incompatible.`, + detail: `Thread '${threadId}' cannot switch from instance '${currentInstanceId}' to '${desiredInstanceId}' because they do not share the same non-empty provider continuation identity. Start a new thread to use that account.`, }); } } + const desiredDriverKind = desiredInfo.driverKind; + if (!isProviderDriverKind(desiredDriverKind)) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredDriverKind)), + method: "thread.turn.start", + detail: `Requested provider instance '${desiredInstanceId}' uses unknown provider driver '${desiredDriverKind}'. The driver is not installed in this build.`, + }); + } + const preferredProvider: ProviderDriverKind = desiredDriverKind; + if (thread.session !== null && hasStartedSession && desiredInstanceId === currentInstanceId) { + yield* rejectStartedThreadModelChangeIfRequired({ + threadId, + currentModelSelection: persistedModelSelection ?? desiredModelSelection, + requestedModelSelection, + }); + } const project = yield* resolveProject(thread.projectId); const effectiveCwd = resolveThreadWorkspaceCwd({ thread, @@ -792,6 +896,16 @@ const make = Effect.gen(function* () { ...(options?.pendingTurnRequestId === undefined ? {} : { pendingTurnSessionId: session.sessionIncarnationId }), + ...(thread.session?.pendingStopRequestId === undefined + ? {} + : { + pendingStopRequestId: thread.session.pendingStopRequestId, + pendingStopProviderInstanceId: thread.session.pendingStopProviderInstanceId ?? null, + pendingStopSessionIncarnationId: + thread.session.pendingStopSessionIncarnationId ?? null, + pendingStopTurnRequestId: thread.session.pendingStopTurnRequestId ?? null, + pendingStopTurnId: thread.session.pendingStopTurnId ?? null, + }), activeTurnRequestId: undefined, startedAt: session.createdAt, // Provider turn ids are not orchestration turn ids. @@ -815,6 +929,13 @@ const make = Effect.gen(function* () { threadId, requestId: options.pendingTurnRequestId, messageId: options.pendingTurnMessageId!, + expectedProviderInstanceId: + options?.expectedProviderInstanceId === undefined + ? (persistedInstanceId ?? null) + : options.expectedProviderInstanceId, + modelSelection: desiredModelSelection, + runtimeMode: desiredRuntimeMode, + interactionMode: desiredInteractionMode, session: sessionBinding, createdAt, }, @@ -822,10 +943,54 @@ const make = Effect.gen(function* () { return (dispatched.eventCount ?? 1) > 0; }); + const quarantineStartedSession = (session: ProviderSession) => { + if (session.providerInstanceId === undefined || session.sessionIncarnationId === undefined) { + return Effect.void; + } + return providerService + .stopSession({ + threadId, + expectedProviderInstanceId: session.providerInstanceId, + expectedSessionIncarnationId: session.sessionIncarnationId, + expectedAdmissionRequestId: options?.pendingTurnRequestId ?? null, + removeBinding: true, + invalidateStartReservation: false, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to quarantine superseded session", { + threadId, + providerInstanceId: session.providerInstanceId, + sessionIncarnationId: session.sessionIncarnationId, + cause: Cause.pretty(cause), + }), + ), + ); + }; + const bindStartedSession = (session: ProviderSession) => + bindSessionToThread(session).pipe( + Effect.tap((bound) => (bound ? Effect.void : quarantineStartedSession(session))), + Effect.onError(() => quarantineStartedSession(session)), + // Once an adapter returned a concrete runtime, either its exact CAS + // binds or that exact runtime is quarantined before interruption lands. + Effect.uninterruptible, + ); + const startAndBindSession = (input?: { + readonly resumeCursor?: unknown; + readonly provider?: ProviderDriverKind; + }) => + Effect.uninterruptibleMask((restore) => + restore(startProviderSession(input)).pipe( + Effect.flatMap((session) => + bindStartedSession(session).pipe(Effect.map((bound) => ({ session, bound }))), + ), + ), + ); + const existingSessionThreadId = thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { - const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; + const runtimeModeChanged = desiredRuntimeMode !== thread.session?.runtimeMode; const cwdChanged = effectiveCwd !== activeSession?.cwd; const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) .sessionModelSwitch; @@ -875,9 +1040,10 @@ const make = Effect.gen(function* () { shouldRestartForModelSelectionChange, hasResumeCursor: resumeCursor !== undefined, }); - const restartedSession = yield* startProviderSession( + const restarted = yield* startAndBindSession( resumeCursor !== undefined ? { resumeCursor } : undefined, ); + const restartedSession = restarted.session; yield* Effect.logInfo("provider command reactor restarted provider session", { threadId, previousSessionId: existingSessionThreadId, @@ -886,11 +1052,31 @@ const make = Effect.gen(function* () { runtimeMode: restartedSession.runtimeMode, cwd: restartedSession.cwd, }); - return (yield* bindSessionToThread(restartedSession)) ? restartedSession : undefined; + return restarted.bound ? restartedSession : undefined; } - const startedSession = yield* startProviderSession(undefined); - return (yield* bindSessionToThread(startedSession)) ? startedSession : undefined; + const compatibleColdTransition = + hasStartedSession && desiredInstanceId !== currentInstanceId + ? yield* providerService.getSessionContinuation?.(threadId) ?? Effect.succeed(null) + : null; + if (hasStartedSession && desiredInstanceId !== currentInstanceId) { + if ( + compatibleColdTransition === null || + compatibleColdTransition.providerInstanceId !== currentInstanceId + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' cannot continue on '${desiredInstanceId}' because its exact persisted provider continuation is unavailable.`, + }); + } + } + const started = yield* startAndBindSession( + compatibleColdTransition === null + ? undefined + : { resumeCursor: compatibleColdTransition.resumeCursor }, + ); + return started.bound ? started.session : undefined; }); const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { @@ -898,9 +1084,11 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly interactionMode?: "default" | "plan"; + readonly runtimeMode: OrchestrationSession["runtimeMode"]; + readonly interactionMode: "default" | "plan"; readonly requestId: CommandId; readonly messageId: MessageId; + readonly admissionIntent?: TurnAdmissionIntent; readonly admissionRequestedAt: string; readonly admissionDeadlineAt: string; readonly createdAt: string; @@ -911,10 +1099,22 @@ const make = Effect.gen(function* () { new Error(`Thread '${input.threadId}' was not found in read model.`), ); } - const requiresExactAdmission = thread.session?.status !== "running"; + const admissionIntent = input.admissionIntent; + const requiresExactAdmission = + admissionIntent === undefined + ? thread.session?.status !== "running" + : admissionIntent.kind !== "steer"; const admittedSession = yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, pendingTurnStart: requiresExactAdmission, + ...(admissionIntent === undefined + ? {} + : { + expectedProviderInstanceId: admissionIntent.expectedProviderInstanceId, + expectedSessionIncarnationId: admissionIntent.expectedSessionIncarnationId, + }), ...(requiresExactAdmission ? { pendingTurnRequestId: input.requestId, @@ -931,9 +1131,6 @@ const make = Effect.gen(function* () { detail: `Turn admission '${input.requestId}' was superseded before provider binding completed.`, }); } - if (input.modelSelection !== undefined) { - threadModelSelections.set(input.threadId, input.modelSelection); - } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService @@ -941,27 +1138,38 @@ const make = Effect.gen(function* () { .pipe( Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)), ); - const sessionModelSwitch = - activeSession === undefined - ? "in-session" - : activeSession.providerInstanceId === undefined - ? yield* new ProviderAdapterRequestError({ - provider: providerErrorLabel(activeSession.provider), - method: "thread.turn.start", - detail: `Active provider session '${activeSession.threadId}' is missing a provider instance id.`, - }) - : (yield* providerService.getCapabilities(activeSession.providerInstanceId)) - .sessionModelSwitch; + const admittedInstanceId = admittedSession.providerInstanceId; + if (admittedInstanceId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(admittedSession.provider), + method: "thread.turn.start", + detail: `Admitted provider session '${admittedSession.threadId}' is missing a provider instance id.`, + }); + } const requestedModelSelection = - input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; + input.admissionIntent?.targetModelSelection ?? + input.modelSelection ?? + threadModelSelections.get(input.threadId) ?? + thread.modelSelection; + if (requestedModelSelection.instanceId !== admittedInstanceId) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(admittedInstanceId)), + method: "thread.turn.start", + detail: `Thread '${input.threadId}' is bound to provider instance '${admittedInstanceId}', but its requested model selection belongs to '${requestedModelSelection.instanceId}'. Start a new thread or explicitly select a model for the bound provider.`, + }); + } + threadModelSelections.set(input.threadId, requestedModelSelection); + const sessionModelSwitch = (yield* providerService.getCapabilities(admittedInstanceId)) + .sessionModelSwitch; const modelForTurn = - sessionModelSwitch === "unsupported" && input.modelSelection === undefined - ? activeSession?.model !== undefined - ? { - ...requestedModelSelection, - model: activeSession.model, - } - : requestedModelSelection + sessionModelSwitch === "unsupported" && + input.modelSelection === undefined && + activeSession?.providerInstanceId === admittedInstanceId && + activeSession.model !== undefined + ? { + ...requestedModelSelection, + model: activeSession.model, + } : input.modelSelection; return { @@ -971,9 +1179,11 @@ const make = Effect.gen(function* () { ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), admissionRequestId: - requiresExactAdmission || thread.session?.activeTurnRequestId === undefined - ? input.requestId - : thread.session.activeTurnRequestId, + input.admissionIntent?.kind === "steer" + ? (input.admissionIntent.expectedActiveTurnRequestId ?? input.requestId) + : requiresExactAdmission || thread.session?.activeTurnRequestId === undefined + ? input.requestId + : thread.session.activeTurnRequestId, sessionIncarnationId: admittedSession.sessionIncarnationId!, }; }); @@ -1365,6 +1575,7 @@ const make = Effect.gen(function* () { Effect.forkScoped, ); + const admissionStopToken = admissionStopTokenForRequest(requestId); const admissionEffect = Effect.gen(function* () { yield* ensureThreadWorktree(thread); const isFirstUserMessageTurn = @@ -1402,22 +1613,40 @@ const make = Effect.gen(function* () { ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), + runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, requestId, messageId: event.payload.messageId, + ...(event.payload.admissionIntent === undefined + ? {} + : { admissionIntent: event.payload.admissionIntent }), admissionRequestedAt, admissionDeadlineAt, createdAt: event.payload.createdAt, }); + if (admissionStopTokens.get(requestId) !== admissionStopToken) { + return yield* Effect.interrupt; + } return yield* providerService.sendTurn(sendTurnRequest); }); - const admissionFiber = yield* admissionEffect.pipe(Effect.forkDetach); + // Event handlers detach provider work so Stop can invalidate a slow start + // immediately. The keyed permit still preserves accepted event order for + // the same thread: an earlier steer reaches the provider before a later + // settings transition starts. + const orderedAdmissionEffect = admissionPermitForThread(event.payload.threadId).withPermit( + admissionEffect, + ); + const admissionFiber = yield* orderedAdmissionEffect.pipe(Effect.forkDetach); admissionFibers.set(requestId, admissionFiber); + admissionFiberThreads.set(requestId, event.payload.threadId); yield* Fiber.await(admissionFiber).pipe( Effect.ensuring( Effect.sync(() => { if (admissionFibers.get(requestId) === admissionFiber) { admissionFibers.delete(requestId); + admissionFiberThreads.delete(requestId); + admissionStopTokens.delete(requestId); + releaseAdmissionLaneIfIdle(event.payload.threadId); } }), ), @@ -1428,7 +1657,10 @@ const make = Effect.gen(function* () { ); const superviseAdmission = Effect.gen(function* () { - if (thread.session?.status === "running") { + if ( + event.payload.admissionIntent?.kind === "steer" || + (event.payload.admissionIntent === undefined && thread.session?.status === "running") + ) { const steeringExit = yield* Fiber.await(admissionFiber); yield* Fiber.interrupt(watchdog); if (Exit.isFailure(steeringExit) && !Cause.hasInterruptsOnly(steeringExit.cause)) { @@ -1437,7 +1669,7 @@ const make = Effect.gen(function* () { kind: "provider.turn.start.failed", summary: "Provider turn start failed", detail: formatFailureDetail(steeringExit.cause), - turnId: thread.session.activeTurnId, + turnId: thread.session?.activeTurnId ?? null, createdAt: yield* nowIso, requestId, }); @@ -1715,44 +1947,147 @@ const make = Effect.gen(function* () { }, ); - const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( - event: Extract, + type PendingSessionStopTarget = { + readonly threadId: ThreadId; + readonly stopRequestId: CommandId; + readonly providerInstanceId: ProviderInstanceId | null; + readonly sessionIncarnationId: NonNullable | null; + readonly turnRequestId: CommandId | null; + readonly turnId: TurnId | null; + readonly createdAt: string; + }; + + const sessionRequestId = (session: OrchestrationSession | null | undefined) => + session?.pendingTurnRequestId ?? + session?.activeTurnRequestId ?? + session?.failedTurnRequestId ?? + null; + + const pendingStopTargetIsCurrent = ( + session: OrchestrationSession | null | undefined, + target: PendingSessionStopTarget, + ) => + session?.pendingStopRequestId === target.stopRequestId && + (session.pendingStopProviderInstanceId ?? null) === target.providerInstanceId && + (session.pendingStopSessionIncarnationId ?? null) === target.sessionIncarnationId && + (session.pendingStopTurnRequestId ?? null) === target.turnRequestId && + (session.pendingStopTurnId ?? null) === target.turnId; + + const clearPendingSessionStop = Effect.fn("clearPendingSessionStop")(function* ( + target: PendingSessionStopTarget, ) { - const thread = yield* resolveThread(event.payload.threadId); - if (!thread) { + const latestThread = yield* resolveThread(target.threadId); + const latestSession = latestThread?.session; + if (!latestThread || !latestSession || !pendingStopTargetIsCurrent(latestSession, target)) { return; } - - const now = event.payload.createdAt; - if (thread.session && thread.session.status !== "stopped") { - yield* providerService.stopSession({ threadId: thread.id }); - } - + const targetStillOwnsProjectedSession = + latestSession.status === "stopped" && + (latestSession.providerInstanceId ?? null) === target.providerInstanceId && + (latestSession.sessionIncarnationId ?? null) === target.sessionIncarnationId && + sessionRequestId(latestSession) === target.turnRequestId; + const clearedAt = + latestSession.updatedAt > target.createdAt ? latestSession.updatedAt : target.createdAt; yield* applyThreadSessionLifecycle({ - threadId: thread.id, - expectedSession: thread.session, + threadId: target.threadId, + expectedSession: latestSession, allowFailedTurnRequestClear: true, session: { - threadId: thread.id, - status: "stopped", - providerName: thread.session?.providerName ?? null, - ...(thread.session?.providerInstanceId !== undefined - ? { providerInstanceId: thread.session.providerInstanceId } - : {}), - runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, - ...(thread.session?.restored === true ? { restored: true } : {}), - ...(thread.session?.startedAt !== undefined ? { startedAt: thread.session.startedAt } : {}), - ...(thread.session?.harnessRefinementStatus !== undefined - ? { harnessRefinementStatus: thread.session.harnessRefinementStatus } + ...latestSession, + ...(targetStillOwnsProjectedSession + ? { + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: undefined, + failedTurnRequestId: undefined, + activeTurnId: null, + } : {}), - activeTurnId: null, - lastError: thread.session?.lastError ?? null, - updatedAt: now, + pendingStopRequestId: undefined, + pendingStopProviderInstanceId: undefined, + pendingStopSessionIncarnationId: undefined, + pendingStopTurnRequestId: undefined, + pendingStopTurnId: undefined, + updatedAt: clearedAt, }, - createdAt: now, + createdAt: clearedAt, + }); + }); + + const stopPendingSessionTarget = Effect.fn("stopPendingSessionTarget")(function* ( + target: PendingSessionStopTarget, + ) { + const currentThread = yield* resolveThread(target.threadId); + const currentSession = currentThread?.session; + // A later Start can coexist with the old target. Never invalidate that new + // reservation; the exact provider identity below still cleans only the old + // runtime. A still-projected stopped target owns the original reservation. + const invalidateStartReservation = + currentSession?.status === "stopped" && pendingStopTargetIsCurrent(currentSession, target); + yield* providerService.stopSession({ + threadId: target.threadId, + expectedProviderInstanceId: target.providerInstanceId, + expectedSessionIncarnationId: target.sessionIncarnationId, + expectedAdmissionRequestId: target.turnRequestId, + invalidateStartReservation, }); + yield* clearPendingSessionStop(target); + releaseAdmissionLaneIfIdle(target.threadId); }); + const processSessionStopRequested = Effect.fn("processSessionStopRequested")( + function* (event: Extract) { + const targetRequestId = event.payload.targetTurnRequestId ?? null; + // Invalidate only the admission captured by the persisted stop intent. + // A later Start has a different globally unique request id and token. + if (targetRequestId !== null && admissionStopTokens.has(targetRequestId)) { + admissionStopTokens.set(targetRequestId, makeAdmissionStopToken()); + } + if (targetRequestId !== null) { + const targetFiber = admissionFibers.get(targetRequestId); + if ( + targetFiber !== undefined && + admissionFiberThreads.get(targetRequestId) === event.payload.threadId + ) { + targetFiber.interruptUnsafe(); + } + } + if (event.commandId === null) { + // Legacy stop events have no durable pending-stop marker. Keep their + // prior exact-stop behavior, but never manufacture a cleanup target. + yield* providerService.stopSession({ + threadId: event.payload.threadId, + ...(event.payload.targetProviderInstanceId !== undefined || + event.payload.targetSessionIncarnationId !== undefined || + event.payload.targetTurnRequestId !== undefined + ? { + expectedProviderInstanceId: event.payload.targetProviderInstanceId ?? null, + expectedSessionIncarnationId: event.payload.targetSessionIncarnationId ?? null, + expectedAdmissionRequestId: targetRequestId, + } + : {}), + }); + return; + } + yield* stopPendingSessionTarget({ + threadId: event.payload.threadId, + stopRequestId: event.commandId, + providerInstanceId: event.payload.targetProviderInstanceId ?? null, + sessionIncarnationId: event.payload.targetSessionIncarnationId ?? null, + turnRequestId: targetRequestId, + turnId: event.payload.targetTurnId ?? null, + createdAt: event.payload.createdAt, + }); + }, + (effect, event) => + effect.pipe( + Effect.ensuring(Effect.sync(() => releaseAdmissionLaneIfIdle(event.payload.threadId))), + ), + ); + const processDomainEvent = Effect.fn("processDomainEvent")(function* ( event: ProviderIntentEvent, ) { @@ -1807,6 +2142,29 @@ const make = Effect.gen(function* () { } }); + const reconcilePendingSessionStops = Effect.fn("reconcilePendingSessionStops")(function* () { + const readModel = yield* projectionSnapshotQuery.getCommandReadModel(); + const pendingTargets = readModel.threads.flatMap((thread) => { + const session = thread.session; + if (session?.pendingStopRequestId === undefined) return []; + return [ + { + threadId: thread.id, + stopRequestId: session.pendingStopRequestId, + providerInstanceId: session.pendingStopProviderInstanceId ?? null, + sessionIncarnationId: session.pendingStopSessionIncarnationId ?? null, + turnRequestId: session.pendingStopTurnRequestId ?? null, + turnId: session.pendingStopTurnId ?? null, + createdAt: session.updatedAt, + } satisfies PendingSessionStopTarget, + ]; + }); + yield* Effect.forEach(pendingTargets, stopPendingSessionTarget, { + concurrency: PROVIDER_TURN_RECONCILIATION_CONCURRENCY, + discard: true, + }); + }); + const reconcileOverdueTurnAdmissions = Effect.fn("reconcileOverdueTurnAdmissions")(function* () { const pendingAdmissions = yield* ( projectionSnapshotQuery.listPendingTurnAdmissions?.() ?? Effect.succeed([]) @@ -1998,22 +2356,33 @@ const make = Effect.gen(function* () { ); }), ); - const reconcileAdmissions = reconcileOverdueTurnAdmissions().pipe( + const reconcileStopsThenAdmissions = reconcilePendingSessionStops().pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt; return Effect.logWarning( - "provider command reactor failed to reconcile overdue turn admissions", + "provider command reactor failed to reconcile pending session stops", { cause: Cause.pretty(cause) }, ); }), + Effect.andThen( + reconcileOverdueTurnAdmissions().pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt; + return Effect.logWarning( + "provider command reactor failed to reconcile overdue turn admissions", + { cause: Cause.pretty(cause) }, + ); + }), + ), + ), ); const activation = yield* ServerActivation; if (activation === undefined) { yield* clearInterrupted; - yield* reconcileAdmissions; + yield* reconcileStopsThenAdmissions; } else { yield* forkParked(clearInterrupted); - yield* forkParked(reconcileAdmissions); + yield* forkParked(reconcileStopsThenAdmissions); } }); @@ -2023,6 +2392,12 @@ const make = Effect.gen(function* () { yield* worker.drain; yield* threadTitleRegenerationWorker.drain; }), + getAdmissionTrackingCounts: () => ({ + fibers: admissionFibers.size, + fiberThreads: admissionFiberThreads.size, + stopTokens: admissionStopTokens.size, + permits: admissionPermits.size, + }), } satisfies ProviderCommandReactorShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index e94b39a85..f80fc543d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -895,6 +895,13 @@ describe("ProviderRuntimeIngestion", () => { threadId, requestId, messageId, + expectedProviderInstanceId: null, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, session: { threadId, status: "starting", @@ -1183,6 +1190,13 @@ describe("ProviderRuntimeIngestion", () => { threadId, requestId, messageId, + expectedProviderInstanceId: null, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, session: { threadId, status: "starting", @@ -1559,15 +1573,180 @@ describe("ProviderRuntimeIngestion", () => { }, }); - thread = await waitForThread( - harness.readModel, - (entry) => - entry.session?.status === "ready" && - entry.session?.activeTurnId === null && - entry.session?.lastError === null, - ); - expect(thread.session?.status).toBe("ready"); - expect(thread.session?.lastError).toBeNull(); + await harness.drain(); + thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + )!; + expect(thread.session?.status).toBe("stopped"); + expect(thread.session?.lastError).toBe("provider crashed"); + }); + + it("rejects same-incarnation lifecycle and targeted turn events behind a durable stop", async () => { + const harness = await createHarness(); + const threadId = ThreadId.make("thread-1"); + const instanceId = ProviderInstanceId.make("codex"); + const incarnationId = RuntimeSessionId.make("session-stopped-barrier"); + const turnRequestId = CommandId.make("cmd-stopped-barrier-turn"); + const turnId = TurnId.make("turn-stopped-barrier"); + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-stopped-barrier-seed"), + threadId, + session: { + threadId, + status: "stopped", + providerName: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + runtimeMode: "approval-required", + sessionIncarnationId: incarnationId, + activeTurnRequestId: turnRequestId, + pendingStopRequestId: CommandId.make("cmd-stopped-barrier-stop"), + pendingStopProviderInstanceId: instanceId, + pendingStopSessionIncarnationId: incarnationId, + pendingStopTurnRequestId: turnRequestId, + pendingStopTurnId: turnId, + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + const eventBase = { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + sessionIncarnationId: incarnationId, + threadId, + admissionRequestId: turnRequestId, + turnId, + createdAt: "2026-01-01T00:00:02.000Z", + } as const; + harness.emit({ + ...eventBase, + type: "session.started", + eventId: EventId.make("evt-stopped-barrier-session-started"), + payload: {}, + }); + harness.emit({ + ...eventBase, + type: "thread.started", + eventId: EventId.make("evt-stopped-barrier-thread-started"), + payload: {}, + }); + harness.emit({ + ...eventBase, + type: "session.state.changed", + eventId: EventId.make("evt-stopped-barrier-state-ready"), + payload: { state: "ready" }, + }); + harness.emit({ + ...eventBase, + type: "turn.completed", + eventId: EventId.make("evt-stopped-barrier-turn-completed"), + status: "completed", + }); + harness.emit({ + ...eventBase, + type: "runtime.error", + eventId: EventId.make("evt-stopped-barrier-runtime-error"), + payload: { message: "late stopped runtime error" }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ + status: "stopped", + sessionIncarnationId: incarnationId, + pendingStopRequestId: CommandId.make("cmd-stopped-barrier-stop"), + activeTurnId: null, + lastError: null, + }); + expect( + thread?.activities.some((activity) => String(activity.id).startsWith("evt-stopped-barrier")), + ).toBe(false); + }); + + it("lets exact exit clear an old pending target without exposing a later incarnation", async () => { + const harness = await createHarness(); + const threadId = ThreadId.make("thread-1"); + const instanceId = ProviderInstanceId.make("codex"); + const oldIncarnationId = RuntimeSessionId.make("session-old-stop-target"); + const newIncarnationId = RuntimeSessionId.make("session-new-after-stop"); + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-new-session-with-old-stop-target"), + threadId, + session: { + threadId, + status: "ready", + providerName: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + runtimeMode: "approval-required", + sessionIncarnationId: newIncarnationId, + pendingStopRequestId: CommandId.make("cmd-old-stop-target"), + pendingStopProviderInstanceId: instanceId, + pendingStopSessionIncarnationId: oldIncarnationId, + pendingStopTurnRequestId: CommandId.make("cmd-old-stop-turn"), + pendingStopTurnId: TurnId.make("turn-old-stop"), + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + const oldEventBase = { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + sessionIncarnationId: oldIncarnationId, + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + } as const; + harness.emit({ + ...oldEventBase, + type: "session.state.changed", + eventId: EventId.make("evt-old-stop-state-before-exit"), + payload: { state: "error", reason: "must not replace new session" }, + }); + harness.emit({ + ...oldEventBase, + type: "session.exited", + eventId: EventId.make("evt-old-stop-exit"), + payload: {}, + }); + await harness.drain(); + + let thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ + status: "ready", + sessionIncarnationId: newIncarnationId, + activeTurnId: null, + lastError: null, + }); + expect(thread?.session?.pendingStopRequestId).toBeUndefined(); + + harness.emit({ + ...oldEventBase, + type: "runtime.error", + eventId: EventId.make("evt-old-stop-error-after-exit"), + payload: { message: "late old error" }, + }); + await harness.drain(); + thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.lastError).toBeNull(); + + harness.emit({ + ...oldEventBase, + sessionIncarnationId: newIncarnationId, + type: "session.state.changed", + eventId: EventId.make("evt-new-session-waiting"), + payload: { state: "waiting", reason: "waiting for approval" }, + }); + await harness.drain(); + thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("running"); + expect(thread?.session?.sessionIncarnationId).toBe(newIncarnationId); }); it("projects harness refinement lifecycle on the session incarnation", async () => { @@ -4981,7 +5160,7 @@ describe("ProviderRuntimeIngestion", () => { expect(completedPayload?.title).toBe("wait for codex review to finish"); }); - it("titles task completion from persisted activities after the description cache is swept", async () => { + it("rejects task completion after the exact session has exited", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -5031,21 +5210,16 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some( + await harness.drain(); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect( + thread?.activities.some( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", ), - ); - - const completed = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", - ); - const completedPayload = - completed?.payload && typeof completed.payload === "object" - ? (completed.payload as Record) - : undefined; - - expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); + ).toBe(false); + expect(thread?.session?.status).toBe("stopped"); }); it("projects structured user input request and resolution as thread activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index de6d528dd..c35103b55 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2159,6 +2159,75 @@ const make = Effect.gen(function* () { let thread = yield* resolveThreadShell(event.threadId); if (!thread) return; + const pendingStopSession = thread.session; + const eventMatchesPendingStop = + pendingStopSession?.pendingStopRequestId !== undefined && + (pendingStopSession.pendingStopProviderInstanceId ?? null) === + (event.providerInstanceId ?? null) && + (pendingStopSession.pendingStopSessionIncarnationId ?? null) === + (event.sessionIncarnationId ?? null); + if (eventMatchesPendingStop) { + // A Stop receipt is a durable lifecycle barrier. The exact old runtime + // may still flush events while its adapter shuts down, but only its + // terminal exit may finish cleanup. A later incarnation can coexist + // with this target and must remain otherwise untouched. + if (event.type !== "session.exited") return; + const stoppedLineageStillProjected = + pendingStopSession.status === "stopped" && + (pendingStopSession.providerInstanceId ?? null) === + (pendingStopSession.pendingStopProviderInstanceId ?? null) && + (pendingStopSession.sessionIncarnationId ?? null) === + (pendingStopSession.pendingStopSessionIncarnationId ?? null); + const cleared = yield* orchestrationEngine.dispatch({ + type: "thread.session.apply-lifecycle", + commandId: yield* providerCommandId(event, "pending-stop-exit-cleanup"), + threadId: thread.id, + expectedStatus: pendingStopSession.status, + expectedProviderInstanceId: pendingStopSession.providerInstanceId ?? null, + expectedSessionIncarnationId: pendingStopSession.sessionIncarnationId ?? null, + expectedPendingTurnRequestId: pendingStopSession.pendingTurnRequestId ?? null, + expectedPendingTurnSessionId: pendingStopSession.pendingTurnSessionId ?? null, + expectedActiveTurnRequestId: pendingStopSession.activeTurnRequestId ?? null, + expectedActiveTurnId: pendingStopSession.activeTurnId ?? null, + expectedFailedTurnRequestId: pendingStopSession.failedTurnRequestId ?? null, + expectedPendingStopRequestId: pendingStopSession.pendingStopRequestId, + expectedPendingStopSessionIncarnationId: + pendingStopSession.pendingStopSessionIncarnationId ?? null, + allowFailedTurnRequestClear: true, + session: { + ...pendingStopSession, + ...(stoppedLineageStillProjected + ? { + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: undefined, + failedTurnRequestId: undefined, + activeTurnId: null, + } + : {}), + pendingStopRequestId: undefined, + pendingStopProviderInstanceId: undefined, + pendingStopSessionIncarnationId: undefined, + pendingStopTurnRequestId: undefined, + pendingStopTurnId: undefined, + updatedAt: event.createdAt, + }, + createdAt: event.createdAt, + }); + if ((cleared.eventCount ?? 0) > 0 && stoppedLineageStillProjected) { + yield* clearTurnStateForSession(thread.id); + } + return; + } + const eventMatchesStoppedSession = + pendingStopSession?.status === "stopped" && + (pendingStopSession.providerInstanceId ?? null) === (event.providerInstanceId ?? null) && + (pendingStopSession.sessionIncarnationId ?? null) === (event.sessionIncarnationId ?? null); + if (eventMatchesStoppedSession && event.type !== "session.exited") return; + if (thread.session?.failedTurnRequestId !== undefined) { // A failed admission quarantines the lineage even for legacy sessions // that do not have an incarnation id. Runtime start/state/output/item/ @@ -2282,6 +2351,9 @@ const make = Effect.gen(function* () { expectedActiveTurnRequestId: observed?.activeTurnRequestId ?? null, expectedActiveTurnId: observed?.activeTurnId ?? null, expectedFailedTurnRequestId: observed?.failedTurnRequestId ?? null, + expectedPendingStopRequestId: observed?.pendingStopRequestId ?? null, + expectedPendingStopSessionIncarnationId: + observed?.pendingStopSessionIncarnationId ?? null, session, createdAt: now, }); diff --git a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts index 65aa9949f..38cac04b2 100644 --- a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts @@ -30,6 +30,14 @@ export interface ProviderCommandReactorShape { * Intended for test use to replace timing-sensitive sleeps. */ readonly drain: Effect.Effect; + + /** Internal counts used by deterministic lifecycle tests. */ + readonly getAdmissionTrackingCounts?: () => { + readonly fibers: number; + readonly fiberThreads: number; + readonly stopTokens: number; + readonly permits: number; + }; } /** diff --git a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts index 7f6eb3d4d..f8c0ffd6d 100644 --- a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts +++ b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, RuntimeSessionId, ThreadId, + TurnId, type OrchestrationReadModel, type OrchestrationSession, } from "@t3tools/contracts"; @@ -149,6 +150,56 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { }), ); + it.effect( + "binds accepted provider settings and the compatible target session in one decision", + () => + Effect.gen(function* () { + const current = makeSession(); + const targetInstanceId = ProviderInstanceId.make("codex_personal"); + const acceptedSession = makeSession({ + providerInstanceId: targetInstanceId, + runtimeMode: "approval-required", + sessionIncarnationId: RuntimeSessionId.make("session-target"), + pendingTurnSessionId: RuntimeSessionId.make("session-target"), + }); + const command = { + type: "thread.session.bind-pending" as const, + commandId: CommandId.make("cmd-bind-compatible-target"), + threadId: THREAD_ID, + requestId: REQUEST_ID, + messageId: MESSAGE_ID, + expectedProviderInstanceId: INSTANCE_ID, + modelSelection: { + instanceId: targetInstanceId, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required" as const, + interactionMode: "plan" as const, + session: acceptedSession, + createdAt: NOW, + }; + + const accepted = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel(current), + }); + const events = Array.isArray(accepted) ? accepted : [accepted]; + expect(events.map((event) => event.type)).toEqual([ + "thread.meta-updated", + "thread.runtime-mode-set", + "thread.interaction-mode-set", + "thread.session-set", + ]); + + const stale = yield* decideOrchestrationCommand({ + command: { ...command, expectedProviderInstanceId: targetInstanceId }, + readModel: makeReadModel(current), + }); + expect(stale).toEqual([]); + }), + ); + it.effect("rejects a pending bind after that admission was quarantined", () => Effect.gen(function* () { const quarantined = makeSession({ @@ -162,6 +213,10 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { threadId: THREAD_ID, requestId: REQUEST_ID, messageId: MESSAGE_ID, + expectedProviderInstanceId: INSTANCE_ID, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", session: quarantined, createdAt: NOW, }, @@ -170,4 +225,252 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { expect(result).toEqual([]); }), ); + + it.effect("keeps branch metadata while rejecting stale provider-shaped metadata", () => + Effect.gen(function* () { + const session = makeSession({ status: "ready" }); + const readModel = makeReadModel(session); + const branchUpdate = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-bound-branch-update"), + threadId: THREAD_ID, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + branch: "fix/accepted-branch", + }, + readModel, + }); + const events = Array.isArray(branchUpdate) ? branchUpdate : [branchUpdate]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "thread.meta-updated", + payload: { branch: "fix/accepted-branch" }, + }); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.modelSelection).toBeUndefined(); + } + + const staleModelFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-stale-model-update"), + threadId: THREAD_ID, + modelSelection: { + instanceId: INSTANCE_ID, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }, + readModel, + }), + ); + expect(staleModelFailure.message).toContain("validated turn transition"); + + const staleRuntimeFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-stale-runtime-update"), + threadId: THREAD_ID, + runtimeMode: "approval-required", + createdAt: NOW, + }, + readModel, + }), + ); + expect(staleRuntimeFailure.message).toContain("validated turn transition"); + + const staleInteractionFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-stale-interaction-update"), + threadId: THREAD_ID, + interactionMode: "plan", + createdAt: NOW, + }, + readModel, + }), + ); + expect(staleInteractionFailure.message).toContain("validated turn transition"); + }), + ); + + it.effect("accepts exact older provider-setting writes as no-event successes", () => + Effect.gen(function* () { + const readModel = makeReadModel(makeSession({ status: "ready" })); + expect( + yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-bound-model-idempotent"), + threadId: THREAD_ID, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + }, + readModel, + }), + ).toEqual([]); + expect( + yield* decideOrchestrationCommand({ + command: { + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-bound-runtime-idempotent"), + threadId: THREAD_ID, + runtimeMode: "full-access", + createdAt: NOW, + }, + readModel, + }), + ).toEqual([]); + expect( + yield* decideOrchestrationCommand({ + command: { + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-bound-interaction-idempotent"), + threadId: THREAD_ID, + interactionMode: "default", + createdAt: NOW, + }, + readModel, + }), + ).toEqual([]); + }), + ); + + it.effect("requires exact admission for running settings changes but keeps plain steering", () => + Effect.gen(function* () { + const running = makeSession({ + status: "running", + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: CommandId.make("request-active"), + activeTurnId: TurnId.make("turn-active"), + }); + const readModel = makeReadModel(running); + const settingsChange = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-running-settings-change"), + threadId: THREAD_ID, + message: { + messageId: MessageId.make("message-running-settings-change"), + role: "user", + text: "restart through exact admission", + attachments: [], + }, + modelSelection: { + instanceId: INSTANCE_ID, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel, + }); + const settingsEvents = Array.isArray(settingsChange) ? settingsChange : [settingsChange]; + expect(settingsEvents.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.session-set", + "thread.turn-start-requested", + ]); + expect(settingsEvents[1]).toMatchObject({ + type: "thread.session-set", + payload: { + session: { + status: "starting", + providerInstanceId: INSTANCE_ID, + pendingTurnRequestId: CommandId.make("cmd-running-settings-change"), + }, + }, + }); + + const accepted = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-running-steer"), + threadId: THREAD_ID, + message: { + messageId: MessageId.make("message-running-steer"), + role: "user", + text: "steer with accepted settings", + attachments: [], + }, + modelSelection: { instanceId: INSTANCE_ID, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel, + }); + const events = Array.isArray(accepted) ? accepted : [accepted]; + expect(events.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.turn-start-requested", + ]); + }), + ); + + it.effect("captures the exact stop target and projects stopped atomically", () => + Effect.gen(function* () { + const turnId = TurnId.make("turn-stop-target"); + const current = makeSession({ + status: "running", + pendingTurnRequestId: undefined, + pendingTurnMessageId: undefined, + pendingTurnRequestedAt: undefined, + pendingTurnDeadlineAt: undefined, + pendingTurnSessionId: undefined, + activeTurnRequestId: REQUEST_ID, + activeTurnId: turnId, + }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.stop", + commandId: CommandId.make("cmd-exact-session-stop"), + threadId: THREAD_ID, + createdAt: NOW, + }, + readModel: makeReadModel(current), + }); + const events = Array.isArray(decided) ? decided : [decided]; + + expect(events.map((event) => event.type)).toEqual([ + "thread.session-stop-requested", + "thread.session-set", + ]); + expect(events[0]).toMatchObject({ + type: "thread.session-stop-requested", + payload: { + targetProviderInstanceId: INSTANCE_ID, + targetSessionIncarnationId: INCARNATION_ID, + targetPendingTurnSessionId: null, + targetTurnRequestId: REQUEST_ID, + targetTurnId: turnId, + }, + }); + expect(events[1]).toMatchObject({ + type: "thread.session-set", + payload: { + session: { + status: "stopped", + providerInstanceId: INSTANCE_ID, + sessionIncarnationId: INCARNATION_ID, + activeTurnRequestId: REQUEST_ID, + pendingStopRequestId: CommandId.make("cmd-exact-session-stop"), + pendingStopProviderInstanceId: INSTANCE_ID, + pendingStopSessionIncarnationId: INCARNATION_ID, + pendingStopTurnRequestId: REQUEST_ID, + pendingStopTurnId: turnId, + activeTurnId: null, + }, + }, + }); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 646adfb41..2d0a5d221 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -671,7 +671,10 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("settled", null, makeSession("ready")), }); const stoppedEvents = Array.isArray(stopped) ? stopped : [stopped]; - expect(stoppedEvents.map((event) => event.type)).toEqual(["thread.session-stop-requested"]); + expect(stoppedEvents.map((event) => event.type)).toEqual([ + "thread.session-stop-requested", + "thread.session-set", + ]); // Re-engaged before the stop was decided (a turn start unsettles the // thread): the stale cleanup stop must not kill the new session. @@ -701,6 +704,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { const unconditionalEvents = Array.isArray(unconditional) ? unconditional : [unconditional]; expect(unconditionalEvents.map((event) => event.type)).toEqual([ "thread.session-stop-requested", + "thread.session-set", ]); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c13a26937..1a2e0c1fa 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -7,6 +7,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -825,6 +826,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const boundInstanceId = thread.session?.providerInstanceId; + const providerSelectionIsBoundNoop = + command.modelSelection !== undefined && + boundInstanceId !== undefined && + Equal.equals(command.modelSelection, thread.modelSelection); + if ( + command.modelSelection !== undefined && + boundInstanceId !== undefined && + !providerSelectionIsBoundNoop + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is bound to provider instance '${boundInstanceId}'. Provider model and options may change only through a validated turn transition.`, + }); + } + const hasNonProviderMetaUpdate = + command.title !== undefined || + command.regenerateTitle === true || + command.branch !== undefined || + command.worktreePath !== undefined || + command.linkedPullRequest !== undefined; + if (providerSelectionIsBoundNoop && !hasNonProviderMetaUpdate) { + return []; + } const branch = command.branch !== undefined && command.expectedBranch !== undefined && @@ -856,7 +881,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.title !== undefined && thread.titleRegeneration != null ? { titleRegeneration: null } : {}), - ...(command.modelSelection !== undefined + ...(command.modelSelection !== undefined && !providerSelectionIsBoundNoop ? { modelSelection: command.modelSelection } : {}), ...(branch !== undefined ? { branch } : {}), @@ -895,11 +920,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.runtime-mode.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + if (thread.session?.providerInstanceId !== undefined) { + if (thread.runtimeMode === command.runtimeMode) return []; + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is bound to provider instance '${thread.session.providerInstanceId}'. Runtime mode may change only through a validated turn transition.`, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -918,11 +950,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.interaction-mode.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + if (thread.session?.providerInstanceId !== undefined) { + if (thread.interactionMode === command.interactionMode) return []; + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is bound to provider instance '${thread.session.providerInstanceId}'. Interaction mode may change only through a validated turn transition.`, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -946,6 +985,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const effectiveModelSelection = command.modelSelection ?? targetThread.modelSelection; + const providerSettingsChanged = + !Equal.equals(effectiveModelSelection, targetThread.modelSelection) || + command.runtimeMode !== targetThread.runtimeMode || + command.interactionMode !== targetThread.interactionMode; const sourceProposedPlan = command.sourceProposedPlan; const sourceThread = sourceProposedPlan ? yield* requireThread({ @@ -1005,6 +1049,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: command.createdAt, }, }; + const admissionIntent = { + kind: + targetThread.session?.providerInstanceId !== undefined && + targetThread.session.providerInstanceId !== effectiveModelSelection.instanceId + ? ("compatible-transition" as const) + : targetThread.session?.status === "running" && !providerSettingsChanged + ? ("steer" as const) + : ("start" as const), + expectedProviderInstanceId: targetThread.session?.providerInstanceId ?? null, + expectedSessionIncarnationId: targetThread.session?.sessionIncarnationId ?? null, + expectedActiveTurnRequestId: targetThread.session?.activeTurnRequestId ?? null, + targetModelSelection: effectiveModelSelection, + }; const turnStartRequestedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", @@ -1017,12 +1074,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, messageId: command.message.messageId, - ...(command.modelSelection !== undefined - ? { modelSelection: command.modelSelection } - : {}), + modelSelection: effectiveModelSelection, + admissionIntent, ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), - runtimeMode: targetThread.runtimeMode, - interactionMode: targetThread.interactionMode, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), admissionRequestedAt, admissionDeadlineAt, @@ -1030,9 +1086,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }; const admissionPendingEvents: Array> = []; - if (targetThread.session?.status !== "running") { - const desiredInstanceId = - command.modelSelection?.instanceId ?? targetThread.modelSelection.instanceId; + if (targetThread.session?.status !== "running" || providerSettingsChanged) { admissionPendingEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", @@ -1049,8 +1103,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" providerName: null, }), status: "starting", - providerInstanceId: targetThread.session?.providerInstanceId ?? desiredInstanceId, - runtimeMode: command.runtimeMode, + ...(targetThread.session?.providerInstanceId !== undefined + ? { providerInstanceId: targetThread.session.providerInstanceId } + : {}), + runtimeMode: targetThread.session?.runtimeMode ?? targetThread.runtimeMode, pendingTurnRequestId: command.commandId, pendingTurnMessageId: command.message.messageId, pendingTurnRequestedAt: admissionRequestedAt, @@ -1443,7 +1499,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ); } } - return { + const targetSession = thread.session; + const stopRequestedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -1453,9 +1510,56 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.session-stop-requested", payload: { threadId: command.threadId, + targetProviderInstanceId: targetSession?.providerInstanceId ?? null, + targetSessionIncarnationId: targetSession?.sessionIncarnationId ?? null, + targetPendingTurnSessionId: targetSession?.pendingTurnSessionId ?? null, + targetTurnRequestId: + targetSession?.pendingTurnRequestId ?? + targetSession?.activeTurnRequestId ?? + targetSession?.failedTurnRequestId ?? + null, + targetTurnId: targetSession?.activeTurnId ?? null, createdAt: command.createdAt, }, }; + // Persist the reverse transition and exact cleanup target in the same + // transaction as the stop intent. `stopped` is understood by frozen + // clients; the additive pending-stop fields are owned by the server and + // survive a restart until exact provider cleanup finishes. + const stoppedEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + causationEventId: stopRequestedEvent.eventId, + type: "thread.session-set", + payload: { + threadId: command.threadId, + session: { + ...(targetSession ?? { + threadId: command.threadId, + providerName: null, + runtimeMode: thread.runtimeMode, + lastError: null, + }), + status: "stopped", + pendingStopRequestId: command.commandId, + pendingStopProviderInstanceId: targetSession?.providerInstanceId ?? null, + pendingStopSessionIncarnationId: targetSession?.sessionIncarnationId ?? null, + pendingStopTurnRequestId: + targetSession?.pendingTurnRequestId ?? + targetSession?.activeTurnRequestId ?? + targetSession?.failedTurnRequestId ?? + null, + pendingStopTurnId: targetSession?.activeTurnId ?? null, + activeTurnId: null, + updatedAt: command.createdAt, + }, + }, + }; + return [stopRequestedEvent, stoppedEvent]; } case "thread.session.apply-lifecycle": { @@ -1473,7 +1577,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" (current?.pendingTurnSessionId ?? null) === command.expectedPendingTurnSessionId && (current?.activeTurnRequestId ?? null) === command.expectedActiveTurnRequestId && (current?.activeTurnId ?? null) === command.expectedActiveTurnId && - (current?.failedTurnRequestId ?? null) === command.expectedFailedTurnRequestId; + (current?.failedTurnRequestId ?? null) === command.expectedFailedTurnRequestId && + (command.expectedPendingStopRequestId === undefined || + (current?.pendingStopRequestId ?? null) === command.expectedPendingStopRequestId) && + (command.expectedPendingStopSessionIncarnationId === undefined || + (current?.pendingStopSessionIncarnationId ?? null) === + command.expectedPendingStopSessionIncarnationId); if (!lineageIsCurrent) return []; if ( current?.failedTurnRequestId !== undefined && @@ -1527,16 +1636,69 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.session?.status !== "starting" || thread.session.pendingTurnRequestId !== command.requestId || thread.session.pendingTurnMessageId !== command.messageId || + (thread.session.providerInstanceId ?? null) !== command.expectedProviderInstanceId || thread.session.failedTurnRequestId !== undefined || thread.session.activeTurnId !== null || command.session.pendingTurnRequestId !== command.requestId || command.session.pendingTurnMessageId !== command.messageId || + command.session.providerInstanceId !== command.modelSelection.instanceId || + command.session.runtimeMode !== command.runtimeMode || command.session.sessionIncarnationId === undefined || command.session.pendingTurnSessionId !== command.session.sessionIncarnationId ) { return []; } - return { + + const acceptedEvents: Array> = []; + if (!Equal.equals(thread.modelSelection, command.modelSelection)) { + acceptedEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + modelSelection: command.modelSelection, + updatedAt: command.createdAt, + }, + }); + } + if (thread.runtimeMode !== command.runtimeMode) { + acceptedEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.runtime-mode-set", + payload: { + threadId: command.threadId, + runtimeMode: command.runtimeMode, + updatedAt: command.createdAt, + }, + }); + } + if (thread.interactionMode !== command.interactionMode) { + acceptedEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.interaction-mode-set", + payload: { + threadId: command.threadId, + interactionMode: command.interactionMode, + updatedAt: command.createdAt, + }, + }); + } + acceptedEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -1548,7 +1710,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, session: command.session, }, - }; + }); + return acceptedEvents; } case "thread.session.set": { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 75bbd04e8..0a84b05f6 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -54,6 +54,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { pending_turn_session_id, active_turn_request_id, failed_turn_request_id, + pending_stop_request_id, + pending_stop_provider_instance_id, + pending_stop_session_incarnation_id, + pending_stop_turn_request_id, + pending_stop_turn_id, active_turn_id, last_error, updated_at @@ -76,6 +81,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.pendingTurnSessionId}, ${row.activeTurnRequestId}, ${row.failedTurnRequestId}, + ${row.pendingStopRequestId}, + ${row.pendingStopProviderInstanceId}, + ${row.pendingStopSessionIncarnationId}, + ${row.pendingStopTurnRequestId}, + ${row.pendingStopTurnId}, ${row.activeTurnId}, ${row.lastError}, ${row.updatedAt} @@ -98,6 +108,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { pending_turn_session_id = excluded.pending_turn_session_id, active_turn_request_id = excluded.active_turn_request_id, failed_turn_request_id = excluded.failed_turn_request_id, + pending_stop_request_id = excluded.pending_stop_request_id, + pending_stop_provider_instance_id = excluded.pending_stop_provider_instance_id, + pending_stop_session_incarnation_id = excluded.pending_stop_session_incarnation_id, + pending_stop_turn_request_id = excluded.pending_stop_turn_request_id, + pending_stop_turn_id = excluded.pending_stop_turn_id, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, updated_at = excluded.updated_at @@ -127,6 +142,11 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { pending_turn_session_id AS "pendingTurnSessionId", active_turn_request_id AS "activeTurnRequestId", failed_turn_request_id AS "failedTurnRequestId", + pending_stop_request_id AS "pendingStopRequestId", + pending_stop_provider_instance_id AS "pendingStopProviderInstanceId", + pending_stop_session_incarnation_id AS "pendingStopSessionIncarnationId", + pending_stop_turn_request_id AS "pendingStopTurnRequestId", + pending_stop_turn_id AS "pendingStopTurnId", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index e7bdac271..54cecd0fe 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -60,6 +60,7 @@ import Migration0045 from "./Migrations/045_AuthSessionClientConnection.ts"; import Migration0046 from "./Migrations/046_ProjectionThreadLinkedPullRequest.ts"; import Migration0047 from "./Migrations/047_ProjectionThreadsUnsettledAt.ts"; import Migration0048 from "./Migrations/048_ProjectionThreadSessionPendingTurnRequest.ts"; +import Migration0049 from "./Migrations/049_ProjectionThreadSessionPendingStop.ts"; /** * Migration loader with all migrations defined inline. * @@ -142,6 +143,7 @@ export const migrationEntries = [ // `unsettled_at` column. [47, "ProjectionThreadsUnsettledAt", Migration0047], [48, "ProjectionThreadSessionPendingTurnRequest", Migration0048], + [49, "ProjectionThreadSessionPendingStop", Migration0049], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts index 88a975c13..821a71c42 100644 --- a/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts @@ -229,6 +229,9 @@ layer("048_ProjectionThreadSessionPendingTurnRequest", (it) => { DELETE FROM projection_state WHERE projector IN ('projection.thread-sessions', 'projection.thread-turns') `; + // The current projector repository reads the additive pending-stop + // columns introduced immediately after this historical migration. + yield* runMigrations({ toMigrationInclusive: 49 }); yield* projectionPipeline.bootstrap; assert.deepStrictEqual(yield* readPendingSessionRows, migrated); }), diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts new file mode 100644 index 000000000..7c6fcf62c --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +it.layer(NodeServices.layer)("049_ProjectionThreadSessionPendingStop", (it) => { + it.effect("adds the exact pending-stop target columns and remains idempotent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "pylon-migration-049-", + }); + const databaseLayer = NodeSqliteClient.layer({ filename: path.join(root, "state.sqlite") }); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 48 }); + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + runtime_mode, + restored, + session_incarnation_id, + active_turn_id, + last_error, + updated_at + ) VALUES ( + 'thread-pending-stop', + 'stopped', + 'codex', + 'codex', + 'approval-required', + 0, + 'session-pending-stop', + NULL, + NULL, + '2026-01-01T00:00:00.000Z' + ) + `; + + const executed = yield* runMigrations({ toMigrationInclusive: 49 }); + assert.deepStrictEqual(executed, [[49, "ProjectionThreadSessionPendingStop"]]); + const columns = yield* sql<{ readonly name: string; readonly type: string }>` + SELECT name, type + FROM pragma_table_info('projection_thread_sessions') + WHERE name LIKE 'pending_stop_%' + ORDER BY cid ASC + `; + assert.deepStrictEqual(columns, [ + { name: "pending_stop_request_id", type: "TEXT" }, + { name: "pending_stop_provider_instance_id", type: "TEXT" }, + { name: "pending_stop_session_incarnation_id", type: "TEXT" }, + { name: "pending_stop_turn_request_id", type: "TEXT" }, + { name: "pending_stop_turn_id", type: "TEXT" }, + ]); + const indexes = yield* sql<{ readonly name: string }>` + SELECT name + FROM pragma_index_list('projection_thread_sessions') + WHERE name = 'idx_projection_thread_sessions_pending_stop' + `; + assert.deepStrictEqual(indexes, [{ name: "idx_projection_thread_sessions_pending_stop" }]); + yield* sql` + UPDATE projection_thread_sessions + SET + pending_stop_request_id = 'cmd-stop', + pending_stop_provider_instance_id = 'codex', + pending_stop_session_incarnation_id = 'session-pending-stop', + pending_stop_turn_request_id = 'cmd-turn', + pending_stop_turn_id = 'turn-1' + WHERE thread_id = 'thread-pending-stop' + `; + }).pipe(Effect.provide(databaseLayer), Effect.scoped); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + assert.deepStrictEqual(yield* runMigrations({ toMigrationInclusive: 49 }), []); + const rows = yield* sql<{ + readonly requestId: string; + readonly providerInstanceId: string; + readonly sessionIncarnationId: string; + readonly turnRequestId: string; + readonly turnId: string; + }>` + SELECT + pending_stop_request_id AS "requestId", + pending_stop_provider_instance_id AS "providerInstanceId", + pending_stop_session_incarnation_id AS "sessionIncarnationId", + pending_stop_turn_request_id AS "turnRequestId", + pending_stop_turn_id AS "turnId" + FROM projection_thread_sessions + WHERE thread_id = 'thread-pending-stop' + `; + assert.deepStrictEqual(rows, [ + { + requestId: "cmd-stop", + providerInstanceId: "codex", + sessionIncarnationId: "session-pending-stop", + turnRequestId: "cmd-turn", + turnId: "turn-1", + }, + ]); + }).pipe(Effect.provide(databaseLayer), Effect.scoped); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.ts new file mode 100644 index 000000000..9befb5838 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const pendingStopColumns = [ + ["pending_stop_request_id", "TEXT"], + ["pending_stop_provider_instance_id", "TEXT"], + ["pending_stop_session_incarnation_id", "TEXT"], + ["pending_stop_turn_request_id", "TEXT"], + ["pending_stop_turn_id", "TEXT"], +] as const; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + const existing = new Set(columns.map((column) => column.name)); + for (const [name, type] of pendingStopColumns) { + if (!existing.has(name)) { + yield* sql.unsafe(`ALTER TABLE projection_thread_sessions ADD COLUMN ${name} ${type}`); + } + } + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_pending_stop + ON projection_thread_sessions(pending_stop_request_id, thread_id) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 801e96bab..ac2e7c940 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -44,6 +44,11 @@ export const ProjectionThreadSession = Schema.Struct({ pendingTurnSessionId: Schema.NullOr(RuntimeSessionId), activeTurnRequestId: Schema.NullOr(CommandId), failedTurnRequestId: Schema.NullOr(CommandId), + pendingStopRequestId: Schema.NullOr(CommandId), + pendingStopProviderInstanceId: Schema.NullOr(ProviderInstanceId), + pendingStopSessionIncarnationId: Schema.NullOr(RuntimeSessionId), + pendingStopTurnRequestId: Schema.NullOr(CommandId), + pendingStopTurnId: Schema.NullOr(TurnId), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), updatedAt: IsoDateTime, diff --git a/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts b/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts index 545b918fa..58223aa3c 100644 --- a/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts +++ b/apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; -import { PrimeAgentDriver } from "./PrimeAgentDriver.ts"; +import { isPrimeAgentProviderPlatformSupported, PrimeAgentDriver } from "./PrimeAgentDriver.ts"; describe("PrimeAgentDriver", () => { it("registers one global Prime Agent driver with contract defaults", () => { @@ -21,4 +21,11 @@ describe("PrimeAgentDriver", () => { PrimeAgentDriver, ]); }); + + it("supports macOS, Linux, and WSL2's Linux runtime only", () => { + expect(isPrimeAgentProviderPlatformSupported("darwin")).toBe(true); + expect(isPrimeAgentProviderPlatformSupported("linux")).toBe(true); + expect(isPrimeAgentProviderPlatformSupported("win32")).toBe(false); + expect(isPrimeAgentProviderPlatformSupported("freebsd")).toBe(false); + }); }); diff --git a/apps/server/src/provider/Drivers/PrimeAgentDriver.ts b/apps/server/src/provider/Drivers/PrimeAgentDriver.ts index 8cfa2f8a7..d7a0ec10b 100644 --- a/apps/server/src/provider/Drivers/PrimeAgentDriver.ts +++ b/apps/server/src/provider/Drivers/PrimeAgentDriver.ts @@ -53,6 +53,19 @@ import { const decodePrimeAgentSettings = Schema.decodeSync(PrimeAgentSettings); const DRIVER_KIND = ProviderDriverKind.make("primeAgent"); +export const PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE = + "Prime Agent is unavailable because this Pylon server is running on native Windows. Run the Pylon server and Prime Agent in WSL2, or connect this client to a Pylon server running in WSL2 or another remote environment."; + +export function isPrimeAgentProviderPlatformSupported(platform: NodeJS.Platform): boolean { + return platform === "darwin" || platform === "linux"; +} + +function unsupportedPlatformMessage(platform: NodeJS.Platform): string { + return platform === "win32" + ? PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE + : `Prime Agent is unavailable on '${platform}'. Run the Pylon server and Prime Agent on macOS, Linux, or WSL2.`; +} + const UPDATE = makeStaticProviderMaintenanceResolver( makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, @@ -97,8 +110,15 @@ export const PrimeAgentDriver: ProviderDriver decodePrimeAgentSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; + if (!isPrimeAgentProviderPlatformSupported(hostPlatform)) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: unsupportedPlatformMessage(hostPlatform), + }); + } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -221,7 +241,7 @@ export const PrimeAgentDriver: ProviderDriver Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), + removeExact: () => Effect.succeed(false), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 5bee84b61..92036c34c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -407,6 +407,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), + removeExact: () => Effect.succeed(false), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 524b35d5d..70584723d 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -30,15 +30,20 @@ import { type CursorSettings, type GrokSettings, type OpenCodeSettings, + type PrimeAgentSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, + TextGenerationError, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import { createModelSelection } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; @@ -49,10 +54,18 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { + PrimeAgentDriver, + PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE, +} from "../Drivers/PrimeAgentDriver.ts"; +import { ProviderDriverError, ProviderUnsupportedError } from "../Errors.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import { makeProviderAdapterRegistry } from "./ProviderAdapterRegistry.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { makeTextGenerationFromRegistry } from "../../textGeneration/TextGeneration.ts"; const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, @@ -136,6 +149,15 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makePrimeAgentConfig = (overrides: Partial): PrimeAgentSettings => ({ + enabled: true, + binaryPath: "prime-agent", + agentHomePath: "", + launchArgs: "", + customModels: [], + ...overrides, +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -292,6 +314,122 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { expect(ghost.unavailableReason).toMatch(/ghostDriver/); }).pipe(Effect.provide(testLayer)), ); + + it.live("fails native Windows Prime closed across every routed entry point", () => { + let processCalls = 0; + let networkProbeCalls = 0; + const trackingSpawner = ChildProcessSpawner.make(() => + Effect.sync(() => { + processCalls += 1; + throw new Error("native Windows Prime must not spawn a process"); + }), + ); + const trackingHttpClient = HttpClient.make((request) => + Effect.sync(() => { + networkProbeCalls += 1; + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + ); + + return Effect.gen(function* () { + expect(yield* HostProcessPlatform).toBe("win32"); + expect(yield* ChildProcessSpawner.ChildProcessSpawner).toBe(trackingSpawner); + expect(yield* HttpClient.HttpClient).toBe(trackingHttpClient); + + const primeId = ProviderInstanceId.make("primeAgent"); + const codexId = ProviderInstanceId.make("codex"); + const primeConfig = makePrimeAgentConfig({}); + + const materialization = yield* PrimeAgentDriver.create({ + instanceId: primeId, + displayName: "Prime Agent", + accentColor: undefined, + environment: [], + enabled: true, + config: primeConfig, + }).pipe(Effect.result); + expect(materialization._tag).toBe("Failure"); + if (materialization._tag === "Failure") { + expect(materialization.failure).toBeInstanceOf(ProviderDriverError); + expect(materialization.failure.detail).toBe(PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE); + } + + const configMap: ProviderInstanceConfigMap = { + [primeId]: { + driver: ProviderDriverKind.make("primeAgent"), + displayName: "Prime Agent", + enabled: true, + config: primeConfig, + }, + [codexId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + enabled: false, + config: makeCodexConfig({}), + }, + }; + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [PrimeAgentDriver, CodexDriver], + configMap, + }); + + expect(yield* registry.getInstance(primeId)).toBeUndefined(); + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toHaveLength(1); + expect(unavailable[0]).toMatchObject({ + instanceId: primeId, + driver: "primeAgent", + availability: "unavailable", + enabled: false, + installed: false, + status: "disabled", + models: [], + }); + expect(unavailable[0]!.message).toContain(PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE); + expect(unavailable[0]!.unavailableReason).toContain( + PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE, + ); + expect(unavailable[0]!.updateState).toBeUndefined(); + expect(unavailable[0]!.versionAdvisory).toBeUndefined(); + + const codex = yield* registry.getInstance(codexId); + expect(codex).toBeDefined(); + expect((yield* codex!.snapshot.getSnapshot).enabled).toBe(false); + + const adapterRegistry = yield* makeProviderAdapterRegistry().pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + ); + const interactiveStart = yield* adapterRegistry.getByInstance(primeId).pipe(Effect.result); + expect(interactiveStart._tag).toBe("Failure"); + if (interactiveStart._tag === "Failure") { + expect(interactiveStart.failure).toBeInstanceOf(ProviderUnsupportedError); + } + + const textGeneration = makeTextGenerationFromRegistry(registry); + const backgroundGeneration = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Native Windows must fail closed", + modelSelection: createModelSelection(primeId, "default"), + }) + .pipe(Effect.result); + expect(backgroundGeneration._tag).toBe("Failure"); + if (backgroundGeneration._tag === "Failure") { + expect(backgroundGeneration.failure).toBeInstanceOf(TextGenerationError); + expect(backgroundGeneration.failure.detail).toBe( + PRIME_AGENT_NATIVE_WINDOWS_UNAVAILABLE_MESSAGE, + ); + } + + expect(processCalls).toBe(0); + expect(networkProbeCalls).toBe(0); + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, trackingSpawner), + Effect.provideService(HttpClient.HttpClient, trackingHttpClient), + Effect.provide(testLayer), + ); + }); }); describe("ProviderInstanceRegistryLive — all drivers slice", () => { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index fb75652e3..cbd7f9a68 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -198,7 +198,7 @@ const buildEntry = (input: { instanceId, displayName: entry.displayName, accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, + reason: createResult.failure.detail, }), }; } diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 9b0d4a178..4308d104f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -504,6 +504,7 @@ const hasMetricSnapshot = ( ); function makeProviderServiceLayer() { + const startReservationCounts: number[] = []; const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); const cursor = makeFakeCodexAdapter(CURSOR_DRIVER, { supportsSideQuestions: false }); @@ -524,7 +525,9 @@ function makeProviderServiceLayer() { const layer = it.layer( Layer.mergeAll( - makeProviderServiceLive().pipe( + makeProviderServiceLive({ + onStartReservationCountChange: (count) => startReservationCounts.push(count), + }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -549,6 +552,7 @@ function makeProviderServiceLayer() { claude, cursor, layer, + startReservationCounts, }; } @@ -1157,6 +1161,27 @@ it.effect( ); routing.layer("ProviderServiceLive routing", (it) => { + it.effect("reclaims start reservations after long historical-thread churn", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + routing.startReservationCounts.length = 0; + + for (let index = 0; index < 200; index += 1) { + const threadId = asThreadId(`thread-reservation-churn-${index}`); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* provider.stopSession({ threadId }); + } + + assert.equal(Math.max(...routing.startReservationCounts), 1); + assert.equal(routing.startReservationCounts.at(-1), 0); + }), + ); + it.effect("routes side questions once without recovering inactive sessions", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1284,6 +1309,41 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("rejects a model selection from another instance before adapter dispatch", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-model-instance-mismatch"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + routing.codex.sendTurn.mockClear(); + routing.codex.startSession.mockClear(); + routing.codex.removeSession(threadId); + + const error = yield* provider + .sendTurn({ + threadId, + input: "must not dispatch", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.match(error.issue, /model selection belongs to 'claudeAgent'/); + assert.equal(routing.codex.sendTurn.mock.calls.length, 0); + assert.equal(routing.codex.startSession.mock.calls.length, 0); + yield* provider.stopSession({ threadId }); + }), + ); + it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -2375,6 +2435,113 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }), ); + it.effect("cancels a first slow start before it can persist or send", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId("thread-stop-slow-first-start"); + const stopCallsBefore = fanout.codex.stopSession.mock.calls.length; + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + fanout.codex.startSession.mockImplementationOnce((input) => + Deferred.succeed(startEntered, input).pipe(Effect.andThen(Deferred.await(releaseStart))), + ); + + const startFiber = yield* provider + .startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + const startInput = yield* Deferred.await(startEntered); + + // No binding exists yet. Stop must still invalidate this exact start and + // return without waiting for adapter creation to finish. + yield* provider.stopSession({ threadId }); + const now = "2026-01-01T00:00:00.000Z"; + yield* Deferred.succeed(releaseStart, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + sessionIncarnationId: startInput.sessionIncarnationId, + status: "ready", + runtimeMode: "full-access", + threadId, + cwd: process.cwd(), + createdAt: now, + updatedAt: now, + }); + + const startExit = yield* Fiber.await(startFiber); + assert.equal(Exit.isFailure(startExit), true); + assert.equal(fanout.codex.stopSession.mock.calls.length, stopCallsBefore + 1); + assert.equal(Option.isNone(yield* directory.getBinding(threadId)), true); + assert.deepEqual( + (yield* provider.listSessions()).filter((session) => session.threadId === threadId), + [], + ); + }), + ); + + it.effect("keeps Stop authoritative while an account transition is starting", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId("thread-stop-slow-transition"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const codexStopCallsBefore = fanout.codex.stopSession.mock.calls.length; + const claudeStopCallsBefore = fanout.claude.stopSession.mock.calls.length; + + const transitionEntered = yield* Deferred.make(); + const releaseTransition = yield* Deferred.make(); + fanout.claude.startSession.mockImplementationOnce((input) => + Deferred.succeed(transitionEntered, input).pipe( + Effect.andThen(Deferred.await(releaseTransition)), + ), + ); + const transitionFiber = yield* provider + .startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + const transitionInput = yield* Deferred.await(transitionEntered); + + yield* provider.stopSession({ threadId }); + const now = "2026-01-01T00:00:00.000Z"; + yield* Deferred.succeed(releaseTransition, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + sessionIncarnationId: transitionInput.sessionIncarnationId, + status: "ready", + runtimeMode: "full-access", + threadId, + cwd: process.cwd(), + createdAt: now, + updatedAt: now, + }); + + assert.equal(Exit.isFailure(yield* Fiber.await(transitionFiber)), true); + assert.equal(fanout.codex.stopSession.mock.calls.length, codexStopCallsBefore + 1); + assert.equal(fanout.claude.stopSession.mock.calls.length, claudeStopCallsBefore + 1); + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + assert.equal(binding?.providerInstanceId, codexInstanceId); + assert.equal(binding?.status, "stopped"); + assert.deepEqual( + (yield* provider.listSessions()).filter((session) => session.threadId === threadId), + [], + ); + }), + ); + it.effect("retains a stopping incarnation until its delayed exit is ingested", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f26a7ecf3..29e2b44c8 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -109,6 +109,8 @@ export interface ProviderServiceLiveOptions { readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; /** Same seam as `issueMcpCredential`, for observing session credential revocation. */ readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread; + /** Test-only observation seam for proving idle reservation lanes are reclaimed. */ + readonly onStartReservationCountChange?: (count: number) => void; } type ProviderServiceMethod = @@ -330,31 +332,78 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly turnId: ProviderRuntimeEvent["turnId"]; } >(); - const startReservations = yield* SynchronizedRef.make( - new Map(), - ); + type StartReservationToken = string; + type StartReservationEntry = { + readonly currentToken: StartReservationToken; + readonly activeTokens: ReadonlySet; + readonly semaphore: Semaphore.Semaphore; + }; + const startReservations = yield* SynchronizedRef.make(new Map()); + const makeStartReservationToken = (): StartReservationToken => NodeCrypto.randomUUID(); + const reportStartReservationCount = options?.onStartReservationCountChange + ? SynchronizedRef.get(startReservations).pipe( + Effect.tap((current) => + Effect.sync(() => options.onStartReservationCountChange?.(current.size)), + ), + Effect.asVoid, + ) + : Effect.void; const reserveStartSession = (threadId: ThreadId) => SynchronizedRef.modify(startReservations, (current) => { const previous = current.get(threadId); + const token = makeStartReservationToken(); + const activeTokens = new Set(previous?.activeTokens ?? []); + activeTokens.add(token); const reservation = { - generation: (previous?.generation ?? 0) + 1, + token, semaphore: previous?.semaphore ?? Semaphore.makeUnsafe(1), }; const next = new Map(current); - next.set(threadId, reservation); + next.set(threadId, { + currentToken: token, + activeTokens, + semaphore: reservation.semaphore, + }); return [reservation, next] as const; - }); - const isStartReservationCurrent = (threadId: ThreadId, generation: number) => + }).pipe(Effect.tap(() => reportStartReservationCount)); + const isStartReservationCurrent = (threadId: ThreadId, token: StartReservationToken) => SynchronizedRef.get(startReservations).pipe( - Effect.map((reservations) => reservations.get(threadId)?.generation === generation), + Effect.map((reservations) => reservations.get(threadId)?.currentToken === token), ); - const releaseStartReservation = (threadId: ThreadId, generation: number) => + const releaseStartReservation = (threadId: ThreadId, token: StartReservationToken) => SynchronizedRef.update(startReservations, (current) => { - if (current.get(threadId)?.generation !== generation) return current; + const previous = current.get(threadId); + if (previous === undefined || !previous.activeTokens.has(token)) return current; + const activeTokens = new Set(previous.activeTokens); + activeTokens.delete(token); const next = new Map(current); - next.delete(threadId); + if (activeTokens.size === 0) { + // Tokens are process-globally unique, so deleting an idle tombstone + // cannot make any older reservation current again through an ABA reset. + next.delete(threadId); + } else { + next.set(threadId, { ...previous, activeTokens }); + } return next; - }); + }).pipe(Effect.tap(() => reportStartReservationCount)); + // Stop is a cancellation boundary, not another participant in the start + // semaphore. Replace the current token before any directory read so a first + // start with no persisted binding still quarantines itself when it returns. + const invalidateStartSession = (threadId: ThreadId) => + SynchronizedRef.update(startReservations, (current) => { + const previous = current.get(threadId); + if (previous === undefined || previous.activeTokens.size === 0) { + // There is no work that can observe this token. Do not retain a + // historical-thread tombstone indefinitely. + return current; + } + const next = new Map(current); + next.set(threadId, { + ...previous, + currentToken: makeStartReservationToken(), + }); + return next; + }).pipe(Effect.tap(() => reportStartReservationCount)); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); /** * Attach the `t3-code` MCP server to the session that is about to start. @@ -907,7 +956,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return yield* reservation.semaphore .withPermit( Effect.gen(function* () { - if (!(yield* isStartReservationCurrent(threadId, reservation.generation))) { + if (!(yield* isStartReservationCurrent(threadId, reservation.token))) { return yield* toValidationError( "ProviderService.startSession", `Provider session start for thread '${threadId}' was superseded by a newer request.`, @@ -1007,7 +1056,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( sessionIncarnationId, }; const requireCurrentStartReservation = Effect.fnUntraced(function* () { - if (yield* isStartReservationCurrent(threadId, reservation.generation)) return; + if (yield* isStartReservationCurrent(threadId, reservation.token)) return; yield* adapter.stopSession(threadId).pipe( Effect.catchCause((cause) => Effect.logWarning("provider.session.stop-superseded-start-failed", { @@ -1022,6 +1071,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( currentSessionIncarnations.delete(threadId); } activeTurnAdmissions.delete(threadId); + // The invalidation can land between the pre-upsert check and the + // directory write. Remove only the exact late incarnation; a + // newer start may already own the thread and must remain intact. + yield* directory.removeExact({ + threadId, + providerInstanceId: resolvedInstanceId, + sessionIncarnationId, + }); return yield* toValidationError( "ProviderService.startSession", `Provider session start for thread '${threadId}' was superseded by a newer request.`, @@ -1073,7 +1130,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), ), ) - .pipe(Effect.ensuring(releaseStartReservation(threadId, reservation.generation))); + .pipe(Effect.ensuring(releaseStartReservation(threadId, reservation.token))); }, ); @@ -1113,6 +1170,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( let metricProvider = "unknown"; let metricModel = input.modelSelection?.model; return yield* Effect.gen(function* () { + if (input.modelSelection !== undefined) { + const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); + if (persistedBinding !== undefined) { + const boundInstanceId = yield* requireBindingInstanceId( + "ProviderService.sendTurn", + persistedBinding, + ); + if (input.modelSelection.instanceId !== boundInstanceId) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Provider session for thread '${input.threadId}' is bound to instance '${boundInstanceId}', but the model selection belongs to '${input.modelSelection.instanceId}'.`, + ); + } + } + } const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.sendTurn", @@ -1120,6 +1192,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); metricProvider = routed.adapter.provider; metricModel = input.modelSelection?.model; + if ( + input.modelSelection !== undefined && + input.modelSelection.instanceId !== routed.instanceId + ) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Provider session for thread '${input.threadId}' is bound to instance '${routed.instanceId}', but the model selection belongs to '${input.modelSelection.instanceId}'.`, + ); + } yield* Effect.annotateCurrentSpan({ "provider.kind": routed.adapter.provider, ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), @@ -1985,6 +2066,77 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); let metricProvider = "unknown"; + // Invalidate before the first directory read. A first-turn start can be + // inside adapter creation without having a persisted binding yet; stop + // must still win that race and let the late start quarantine itself. + if (input.invalidateStartReservation !== false) { + yield* invalidateStartSession(input.threadId); + } + const activeAdmission = activeTurnAdmissions.get(input.threadId); + if ( + input.expectedAdmissionRequestId === undefined || + (input.expectedAdmissionRequestId !== null && + activeAdmission?.requestId === input.expectedAdmissionRequestId) + ) { + activeTurnAdmissions.delete(input.threadId); + } + + const targetIsExact = + input.expectedProviderInstanceId !== undefined || + input.expectedSessionIncarnationId !== undefined; + const binding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); + const currentIncarnation = currentSessionIncarnations.get(input.threadId); + const persistedIncarnationId = + binding === undefined + ? undefined + : readRuntimePayloadString(binding.runtimePayload, "sessionIncarnationId"); + const instanceMatches = + input.expectedProviderInstanceId === undefined || + (input.expectedProviderInstanceId === null + ? binding === undefined && currentIncarnation === undefined + : (binding?.providerInstanceId ?? currentIncarnation?.instanceId) === + input.expectedProviderInstanceId); + const incarnationMatches = + input.expectedSessionIncarnationId === undefined || + (input.expectedSessionIncarnationId === null + ? persistedIncarnationId === undefined && currentIncarnation === undefined + : (persistedIncarnationId ?? currentIncarnation?.id) === + input.expectedSessionIncarnationId); + if (!instanceMatches || !incarnationMatches) { + return; + } + + if (binding === undefined) { + // A target with no directory row can only be an in-flight start. Its + // reservation was invalidated above; if the exact adapter incarnation + // is already known, stop it now as well. + if ( + targetIsExact && + input.expectedProviderInstanceId != null && + input.expectedSessionIncarnationId != null && + currentIncarnation !== undefined + ) { + metricProvider = currentIncarnation.adapter.provider; + yield* currentIncarnation.adapter.stopSession(input.threadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider.session.stop-exact-unbound-failed", { + threadId: input.threadId, + provider: currentIncarnation.adapter.provider, + cause, + }), + ), + ); + } + if ( + !targetIsExact || + currentSessionIncarnations.get(input.threadId) === currentIncarnation + ) { + yield* clearMcpSession(input.threadId); + currentSessionIncarnations.delete(input.threadId); + } + return; + } + return yield* Effect.gen(function* () { const routed = yield* resolveRoutableSession({ threadId: input.threadId, @@ -1996,6 +2148,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.operation": "stop-session", "provider.kind": routed.adapter.provider, "provider.thread_id": input.threadId, + ...(input.expectedSessionIncarnationId + ? { "provider.session_incarnation_id": input.expectedSessionIncarnationId } + : {}), }); if (routed.isActive) { yield* routed.adapter @@ -2005,20 +2160,49 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // an asynchronous relay after stopSession returns. Keep this exact // incarnation routable until processRuntimeEvent ingests that exit. A // later start safely replaces the map entry before its adapter starts. - } else { - yield* clearMcpSession(input.threadId); + } else if ( + !targetIsExact || + (currentIncarnation !== undefined && + currentSessionIncarnations.get(input.threadId) === currentIncarnation) + ) { currentSessionIncarnations.delete(input.threadId); } - activeTurnAdmissions.delete(input.threadId); - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - providerInstanceId: routed.instanceId, - status: "stopped", - runtimePayload: { - activeTurnId: null, - }, - }); + yield* clearMcpSession(input.threadId); + + const latestBinding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); + const latestIncarnationId = + latestBinding === undefined + ? undefined + : readRuntimePayloadString(latestBinding.runtimePayload, "sessionIncarnationId"); + const latestIsTarget = + latestBinding !== undefined && + (input.expectedProviderInstanceId === undefined || + latestBinding.providerInstanceId === input.expectedProviderInstanceId) && + (input.expectedSessionIncarnationId === undefined || + latestIncarnationId === input.expectedSessionIncarnationId); + if (input.removeBinding === true) { + if ( + latestIsTarget && + input.expectedProviderInstanceId != null && + input.expectedSessionIncarnationId != null + ) { + yield* directory.removeExact({ + threadId: input.threadId, + providerInstanceId: input.expectedProviderInstanceId, + sessionIncarnationId: input.expectedSessionIncarnationId, + }); + } + } else if (!targetIsExact || latestIsTarget) { + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + }, + }); + } yield* analytics.record("provider.session.stopped", { provider: routed.adapter.provider, }); @@ -2080,6 +2264,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const getSessionContinuation: NonNullable< + ProviderService.ProviderServiceShape["getSessionContinuation"] + > = Effect.fn("getSessionContinuation")(function* (threadId) { + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + if (binding?.resumeCursor === undefined || binding.resumeCursor === null) return null; + return { + providerInstanceId: yield* requireBindingInstanceId( + "ProviderService.getSessionContinuation", + binding, + ), + resumeCursor: binding.resumeCursor, + }; + }); + const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")( function* () { const currentAdapters = yield* getAdapterEntries; @@ -2360,6 +2558,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( refineSessionHarness, stopSession, listSessions, + getSessionContinuation, listSessionsForInstance, getCapabilities, getInstanceInfo, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 23075bd9a..70b004702 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -4,6 +4,7 @@ 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 * as Semaphore from "effect/Semaphore"; import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; @@ -100,52 +101,97 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const mutationPermit = Semaphore.makeUnsafe(1); const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { - const existing = yield* repository - .getByThreadId({ threadId: binding.threadId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); - - const existingRuntime = Option.getOrUndefined(existing); - const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; - if (!resolvedThreadId) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "threadId must be a non-empty string.", - }); - } - - const now = DateTime.formatIso(yield* DateTime.now); - const providerChanged = - existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; - const providerInstanceId = - binding.providerInstanceId ?? (!providerChanged ? existingRuntime?.providerInstanceId : null); - if (providerInstanceId === null || providerInstanceId === undefined) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "providerInstanceId is required for provider session runtime bindings.", - }); - } - yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + return yield* mutationPermit.withPermit( + Effect.gen(function* () { + const existing = yield* repository + .getByThreadId({ threadId: binding.threadId }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")), + ); + + const existingRuntime = Option.getOrUndefined(existing); + const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; + if (!resolvedThreadId) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "threadId must be a non-empty string.", + }); + } + + const now = DateTime.formatIso(yield* DateTime.now); + const providerChanged = + existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; + const providerInstanceId = + binding.providerInstanceId ?? + (!providerChanged ? existingRuntime?.providerInstanceId : null); + if (providerInstanceId === null || providerInstanceId === undefined) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "providerInstanceId is required for provider session runtime bindings.", + }); + } + yield* repository + .upsert({ + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + }), + ); + }); + + const removeExact: ProviderSessionDirectoryShape["removeExact"] = Effect.fn(function* (input) { + return yield* mutationPermit.withPermit( + Effect.gen(function* () { + const existing = Option.getOrUndefined( + yield* repository + .getByThreadId({ threadId: input.threadId }) + .pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.removeExact:getByThreadId"), + ), + ), + ); + const payload = existing?.runtimePayload; + const persistedIncarnationId = + isRecord(payload) && typeof payload.sessionIncarnationId === "string" + ? payload.sessionIncarnationId + : undefined; + if ( + existing?.providerInstanceId !== input.providerInstanceId || + persistedIncarnationId !== input.sessionIncarnationId + ) { + return false; + } + yield* repository + .deleteByThreadId({ threadId: input.threadId }) + .pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.removeExact:deleteByThreadId"), + ), + ); + return true; + }), + ); }); const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => @@ -186,6 +232,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { upsert, getProvider, getBinding, + removeExact, listThreadIds, listBindings, } satisfies ProviderSessionDirectoryShape; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 68becc409..93b6318b3 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -199,6 +199,14 @@ export interface ProviderServiceShape { */ readonly listSessions: () => Effect.Effect>; + /** Read the exact durable continuation even when no adapter runtime is live. */ + readonly getSessionContinuation?: ( + threadId: ThreadId, + ) => Effect.Effect< + { readonly providerInstanceId: ProviderInstanceId; readonly resumeCursor: unknown } | null, + ProviderServiceError + >; + /** Inventory exactly one configured provider instance without coupling failures. */ readonly listSessionsForInstance?: ( instanceId: ProviderInstanceId, diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f..766633634 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -2,6 +2,7 @@ import type { ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, + RuntimeSessionId, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -53,6 +54,13 @@ export interface ProviderSessionDirectoryShape { threadId: ThreadId, ) => Effect.Effect, ProviderSessionDirectoryReadError>; + /** Atomically removes only the exact runtime incarnation supplied by its owner. */ + readonly removeExact: (input: { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + readonly sessionIncarnationId: RuntimeSessionId; + }) => Effect.Effect; + readonly listThreadIds: () => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts index 0a2bf7c90..35af06fbe 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts @@ -9,7 +9,6 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { isPathInside, loadPrimeAgentDaemonBridge, - locatePrimeAgentPublicPackage, PRIME_AGENT_DAEMON_PROTOCOL_NAME, PRIME_AGENT_MIN_DAEMON_PROTOCOL_VERSION, PRIME_AGENT_NEGOTIATED_DAEMON_SESSION_CAPABILITIES_FEATURE, @@ -156,41 +155,6 @@ describe("PrimeAgentDaemonBridge", () => { }), ); - it.effect("locates a Windows npm wrapper through its sibling package bin", () => - Effect.gen(function* () { - const npmRoot = makeTemporaryDirectory(); - const packageRoot = NodePath.join(npmRoot, "node_modules", "prime-agent"); - const dist = NodePath.join(packageRoot, "dist"); - NodeFS.mkdirSync(dist, { recursive: true }); - const cliPath = NodePath.join(dist, "cli.js"); - const entryPath = NodePath.join(dist, "index.js"); - const wrapperPath = NodePath.join(npmRoot, "prime-agent.cmd"); - NodeFS.writeFileSync(cliPath, "#!/usr/bin/env node\n"); - NodeFS.writeFileSync(entryPath, "export const VERSION = '0.8.1';\n"); - NodeFS.writeFileSync( - NodePath.join(packageRoot, "package.json"), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - name: "prime-agent", - version: "0.8.1", - type: "module", - bin: { "prime-agent": "dist/cli.js" }, - exports: { ".": { import: "./dist/index.js" } }, - }), - ); - NodeFS.writeFileSync( - wrapperPath, - '@ECHO off\r\n"%~dp0\\node.exe" "%~dp0\\node_modules\\prime-agent\\dist\\cli.js" %*\r\n', - ); - - const located = yield* locatePrimeAgentPublicPackage(wrapperPath); - - expect(located.packageRoot).toBe(NodeFS.realpathSync(packageRoot)); - expect(located.moduleEntryPath).toBe(NodeFS.realpathSync(entryPath)); - expect(located.version).toBe("0.8.1"); - }), - ); - it.effect("follows an executable symlink into an npm-style package", () => Effect.gen(function* () { const pkg = makePackage(); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts index e2ba72e5f..936056de8 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.ts @@ -249,7 +249,6 @@ const packageIdentitySchema = Schema.Struct({ const primeAgentPackageSchema = Schema.Struct({ name: Schema.Literal("prime-agent"), version: Schema.String, - bin: Schema.optional(Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)])), exports: Schema.Union([ Schema.String, Schema.Struct({ ".": Schema.String }), @@ -292,93 +291,6 @@ async function readJson(filePath: string): Promise { return JSON.parse(source) as unknown; } -function packageBinEntry( - manifest: LocatedPackage["manifest"], - commandName: string, -): string | undefined { - if (Predicate.isString(manifest.bin)) { - return commandName === "prime-agent" ? manifest.bin : undefined; - } - return manifest.bin?.[commandName]; -} - -async function locatePrimeAgentWrapperPackage( - binaryPath: string, - canonicalWrapperPath: string, -): Promise { - const wrapperName = NodePath.basename(binaryPath).replace(/\.(?:cmd|ps1)$/iu, ""); - if (wrapperName.toLowerCase() !== "prime-agent") return undefined; - - const siblingRoot = NodePath.join( - NodePath.dirname(NodePath.resolve(binaryPath)), - "node_modules", - "prime-agent", - ); - let rawManifest: unknown; - try { - rawManifest = await readJson(NodePath.join(siblingRoot, "package.json")); - } catch (cause) { - if ( - Predicate.isObject(cause) && - "code" in cause && - (cause.code === "ENOENT" || cause.code === "ENOTDIR") - ) { - return undefined; - } - throw bridgeError( - binaryPath, - "invalid-package-manifest", - `Could not read the wrapper-owned prime-agent package at '${siblingRoot}'.`, - cause, - ); - } - - const manifest = decodePrimeAgentPackage(rawManifest); - if (Option.isNone(manifest)) { - throw bridgeError( - binaryPath, - "invalid-package-manifest", - `The wrapper-owned package at '${siblingRoot}' is not a valid prime-agent public package.`, - ); - } - const binEntry = packageBinEntry(manifest.value, wrapperName.toLowerCase()); - if (!binEntry) { - throw bridgeError( - binaryPath, - "wrong-package", - "The Prime Agent wrapper is not bound to the package's prime-agent bin entry.", - ); - } - - try { - const [canonicalRoot, canonicalBin, wrapperSource] = await Promise.all([ - NodeFSP.realpath(siblingRoot), - NodeFSP.realpath(NodePath.resolve(siblingRoot, binEntry)), - NodeFSP.readFile(canonicalWrapperPath, "utf8"), - ]); - const binStat = await NodeFSP.stat(canonicalBin); - const expectedReference = `node_modules/prime-agent/${binEntry.replace(/^\.\//u, "")}` - .replaceAll("\\", "/") - .toLowerCase(); - const normalizedWrapperSource = wrapperSource.replaceAll("\\", "/").toLowerCase(); - if ( - !binStat.isFile() || - !isPathInside(canonicalRoot, canonicalBin) || - !normalizedWrapperSource.includes(expectedReference) - ) { - throw new Error("wrapper does not reference its package-owned bin file"); - } - return { root: canonicalRoot, manifest: manifest.value }; - } catch (cause) { - throw bridgeError( - binaryPath, - "wrong-package", - "The Prime Agent wrapper is not safely bound to its sibling package.", - cause, - ); - } -} - async function locatePrimeAgentPackage(binaryPath: string): Promise { let canonicalPath: string; try { @@ -393,10 +305,6 @@ async function locatePrimeAgentPackage(binaryPath: string): Promise= 0; index -= 1) { - const [candidate, value] = entries[index]!; - if (candidate.toUpperCase() === name.toUpperCase()) return value; - } - return undefined; -} - -function environmentHome( - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -): string | undefined { - const pathApi = platform === "win32" ? NodePath.win32 : NodePath.posix; - const candidates = - platform === "win32" - ? [ - environmentValue(environment, "USERPROFILE", platform)?.trim(), - `${environmentValue(environment, "HOMEDRIVE", platform)?.trim() ?? ""}${environmentValue(environment, "HOMEPATH", platform)?.trim() ?? ""}`, - ] - : [environment.HOME?.trim()]; - return candidates.find((candidate): candidate is string => - Boolean(candidate && pathApi.isAbsolute(candidate)), - ); } /** Prime's effective agent home for the selected instance environment. */ @@ -209,23 +174,17 @@ export function resolvePrimeAgentHomePath( if (configured) return resolveProviderHomePath(configured); if (!options.processEnv) return path.join(NodeOS.homedir(), ".prime", "agent"); - const platform = options.platform; - if (!platform) return undefined; - const pathApi = platform === "win32" ? NodePath.win32 : NodePath.posix; - const home = environmentHome(options.processEnv, platform); - const environmentAgentDir = environmentValue( - options.processEnv, - PRIME_AGENT_HOME_ENV, - platform, - )?.trim(); - if (!environmentAgentDir) return home ? pathApi.join(home, ".prime", "agent") : undefined; - if (environmentAgentDir === "~") return home; - if (environmentAgentDir.startsWith("~/") || environmentAgentDir.startsWith("~\\")) { - return home ? pathApi.join(home, environmentAgentDir.slice(2)) : undefined; + const home = options.processEnv.HOME?.trim(); + const absoluteHome = home && path.isAbsolute(home) ? path.normalize(home) : undefined; + const environmentAgentDir = options.processEnv[PRIME_AGENT_HOME_ENV]?.trim(); + if (!environmentAgentDir) { + return absoluteHome ? path.join(absoluteHome, ".prime", "agent") : undefined; + } + if (environmentAgentDir === "~") return absoluteHome; + if (environmentAgentDir.startsWith("~/")) { + return absoluteHome ? path.join(absoluteHome, environmentAgentDir.slice(2)) : undefined; } - return pathApi.isAbsolute(environmentAgentDir) - ? pathApi.normalize(environmentAgentDir) - : undefined; + return path.isAbsolute(environmentAgentDir) ? path.normalize(environmentAgentDir) : undefined; } /** diff --git a/apps/server/src/provider/providerUnavailable.ts b/apps/server/src/provider/providerUnavailable.ts new file mode 100644 index 000000000..5a33991d4 --- /dev/null +++ b/apps/server/src/provider/providerUnavailable.ts @@ -0,0 +1,20 @@ +import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; + +/** Exact configured shadow lookup shared by interactive and background routing. */ +export function findUnavailableProviderInstance( + providers: ReadonlyArray, + instanceId: ProviderInstanceId, +): ServerProvider | undefined { + return providers.find( + (provider) => provider.instanceId === instanceId && provider.availability === "unavailable", + ); +} + +/** Client-neutral remediation carried by an unavailable provider shadow. */ +export function providerUnavailableDetail(provider: ServerProvider): string { + return ( + provider.unavailableReason?.trim() || + provider.message?.trim() || + `Provider instance '${provider.instanceId}' is unavailable in this environment.` + ); +} diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index ad2a0e40e..0b5974384 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -146,6 +146,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + removeExact: () => Effect.succeed(false), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -215,6 +216,7 @@ it.effect( }), ), upsert: () => Effect.fail(writeFailure), + removeExact: () => Effect.succeed(false), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -252,6 +254,7 @@ it.effect("retries failed projections and continues after a persistent failure", directory: { getBinding: () => Effect.succeed(Option.none()), upsert: () => Effect.void, + removeExact: () => Effect.succeed(false), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -300,6 +303,7 @@ it.effect("does not fail startup when the live provider session inventory cannot Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), + removeExact: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), diff --git a/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts b/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts index af0bcf7fc..e152b9539 100644 --- a/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts +++ b/apps/server/src/textGeneration/PrimeAgentTextGeneration.platform.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { PrimeAgentSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -21,17 +20,16 @@ const TestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "pylon-prime-platform-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); -it.layer(TestLayer)("PrimeAgentTextGeneration platform subprocess", (it) => { +it.layer(TestLayer)("PrimeAgentTextGeneration POSIX subprocess", (it) => { it.effect("spawns the selected public ESM SDK with exact instance affinity and cleans up", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const hostPlatform = yield* HostProcessPlatform; const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "pylon prime platform with spaces ", }); const packageRoot = path.join(root, "Selected Prime Package With Spaces"); - const binaryName = hostPlatform === "win32" ? "prime-agent.cmd" : "prime-agent"; + const binaryName = "prime-agent"; const binaryPath = path.join(packageRoot, "bin", binaryName); const sdkEntryPath = path.join(packageRoot, "public sdk entry.js"); const cwd = path.join(root, "Project Working Directory With Spaces"); @@ -52,13 +50,8 @@ it.layer(TestLayer)("PrimeAgentTextGeneration platform subprocess", (it) => { exports: { ".": { import: "./public sdk entry.js" } }, }), ); - yield* fileSystem.writeFileString( - binaryPath, - hostPlatform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/usr/bin/env node\n", - ); - if (hostPlatform !== "win32") { - yield* fileSystem.chmod(binaryPath, 0o755); - } + yield* fileSystem.writeFileString(binaryPath, "#!/usr/bin/env node\n"); + yield* fileSystem.chmod(binaryPath, 0o755); yield* fileSystem.writeFileString(sdkEntryPath, FAKE_PUBLIC_SDK); const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts b/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts index ce25577b2..86b8a0c0a 100644 --- a/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts +++ b/apps/server/src/textGeneration/PrimeAgentTextGeneration.test.ts @@ -23,7 +23,6 @@ import { FAKE_PUBLIC_SDK } from "./PrimeAgentTextGeneration.test-fixture.ts"; import { hasStablePrimeAgentImageFileIdentity, makePrimeAgentTextGeneration, - normalizePrimeAgentTextGenerationEnvironment, resolvePrimeAgentTextGenerationHomePath, } from "./PrimeAgentTextGeneration.ts"; @@ -179,7 +178,6 @@ it.layer(TestLayer)("PrimeAgentTextGeneration", (it) => { PRIME_AGENT_CODING_AGENT_DIR: "/explicit/prime-home", }, cwd: "/srv/project", - platform: "linux", }), ).toBe("/explicit/prime-home"); expect( @@ -189,7 +187,6 @@ it.layer(TestLayer)("PrimeAgentTextGeneration", (it) => { PRIME_AGENT_CODING_AGENT_DIR: "~/.prime-instance", }, cwd: "/srv/project", - platform: "linux", }), ).toBe("/srv/instance-home/.prime-instance"); expect( @@ -199,74 +196,17 @@ it.layer(TestLayer)("PrimeAgentTextGeneration", (it) => { PRIME_AGENT_CODING_AGENT_DIR: "relative-prime-home", }, cwd: "/srv/project", - platform: "darwin", }), ).toBe("/srv/project/relative-prime-home"); expect( resolvePrimeAgentTextGenerationHomePath({ environment: { HOME: "/srv/instance-home" }, cwd: "/srv/project", - platform: "linux", }), ).toBe("/srv/instance-home/.prime/agent"); }), ); - it.effect("uses Windows USERPROFILE then HOMEDRIVE and HOMEPATH", () => - Effect.sync(() => { - expect( - resolvePrimeAgentTextGenerationHomePath({ - environment: { - USERPROFILE: "C:\\Users\\Instance", - HOMEDRIVE: "D:", - HOMEPATH: "\\Ignored", - PRIME_AGENT_CODING_AGENT_DIR: "~\\prime-instance", - }, - cwd: "C:\\project", - platform: "win32", - }), - ).toBe("C:\\Users\\Instance\\prime-instance"); - expect( - resolvePrimeAgentTextGenerationHomePath({ - environment: { HOMEDRIVE: "D:", HOMEPATH: "\\Profiles\\DriveUser" }, - cwd: "D:\\project", - platform: "win32", - }), - ).toBe("D:\\Profiles\\DriveUser\\.prime\\agent"); - const normalized = normalizePrimeAgentTextGenerationEnvironment( - { - PATH: "C:\\base-bin", - USERPROFILE: "C:\\Users\\Base", - HOME: "C:\\home-base", - CUSTOM_VALUE: "base", - Path: "D:\\instance-bin", - UserProfile: "D:\\Users\\Instance", - Home: "D:\\home-instance", - custom_value: "instance", - }, - "win32", - ); - expect(normalized).toMatchObject({ - PATH: "D:\\instance-bin", - USERPROFILE: "D:\\Users\\Instance", - HOME: "D:\\home-instance", - CUSTOM_VALUE: "instance", - }); - expect( - resolvePrimeAgentTextGenerationHomePath({ - environment: { - USERPROFILE: "C:\\Users\\Base", - PRIME_AGENT_CODING_AGENT_DIR: "C:\\prime-base", - UserProfile: "D:\\Users\\Instance", - prime_agent_coding_agent_dir: "~\\prime-instance", - }, - cwd: "D:\\project", - platform: "win32", - }), - ).toBe("D:\\Users\\Instance\\prime-instance"); - }), - ); - it.effect("supports all four structured operations and shared sanitizers", () => withFakePrime({}, ({ textGeneration }) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts b/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts index 0f5a2711c..9fe097790 100644 --- a/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts +++ b/apps/server/src/textGeneration/PrimeAgentTextGeneration.ts @@ -10,7 +10,6 @@ import { TextGenerationError, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { PRIME_AGENT_TEXT_GENERATION_HELPER_SOURCE } from "@t3tools/shared/primeAgentTextGenerationHelper"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { resolveCommandPath } from "@t3tools/shared/shell"; @@ -78,82 +77,30 @@ export interface PrimeAgentTextGenerationOptions { readonly beforeImageOpen?: ((filePath: string) => Effect.Effect) | undefined; } -function pathApiForPlatform(platform: NodeJS.Platform) { - return platform === "win32" ? NodePath.win32 : NodePath.posix; -} - -function environmentValueLast( - environment: NodeJS.ProcessEnv, - name: string, - platform: NodeJS.Platform, -): string | undefined { - if (platform !== "win32") return environment[name]; - const entries = Object.entries(environment); - for (let index = entries.length - 1; index >= 0; index -= 1) { - const [candidate, value] = entries[index]!; - if (candidate.toUpperCase() === name.toUpperCase()) return value; - } - return undefined; -} - -/** Apply Windows' case-insensitive, last-assignment-wins environment semantics. */ -export function normalizePrimeAgentTextGenerationEnvironment( - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -): NodeJS.ProcessEnv { - if (platform !== "win32") return { ...environment }; - const normalized = new Map(); - for (const [name, value] of Object.entries(environment)) { - normalized.set(name.toUpperCase(), value); - } - return Object.fromEntries(normalized); -} - -function effectiveEnvironmentHome( - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -): string { - const pathApi = pathApiForPlatform(platform); - const candidates = - platform === "win32" - ? [ - environmentValueLast(environment, "USERPROFILE", platform)?.trim(), - `${environmentValueLast(environment, "HOMEDRIVE", platform)?.trim() ?? ""}${environmentValueLast(environment, "HOMEPATH", platform)?.trim() ?? ""}`, - ] - : [environment.HOME?.trim()]; - const configuredHome = candidates.find((candidate): candidate is string => - Boolean(candidate && pathApi.isAbsolute(candidate)), - ); - if (configuredHome) { - return pathApi.normalize(configuredHome); - } - return NodeOS.homedir(); +function effectiveEnvironmentHome(environment: NodeJS.ProcessEnv): string { + const configuredHome = environment.HOME?.trim(); + return configuredHome && NodePath.isAbsolute(configuredHome) + ? NodePath.normalize(configuredHome) + : NodeOS.homedir(); } /** Resolve Prime's final merged agent-dir environment for one helper cwd. */ export function resolvePrimeAgentTextGenerationHomePath(input: { readonly environment: NodeJS.ProcessEnv; readonly cwd: string; - readonly platform: NodeJS.Platform; }): string { - const platform = input.platform; - const pathApi = pathApiForPlatform(platform); - const effectiveHome = effectiveEnvironmentHome(input.environment, platform); - const configured = environmentValueLast( - input.environment, - PRIME_AGENT_HOME_ENV, - platform, - )?.trim(); - if (!configured) return pathApi.join(effectiveHome, ".prime", "agent"); + const effectiveHome = effectiveEnvironmentHome(input.environment); + const configured = input.environment[PRIME_AGENT_HOME_ENV]?.trim(); + if (!configured) return NodePath.join(effectiveHome, ".prime", "agent"); const expanded = configured === "~" ? effectiveHome - : configured.startsWith("~/") || configured.startsWith("~\\") - ? pathApi.join(effectiveHome, configured.slice(2)) + : configured.startsWith("~/") + ? NodePath.join(effectiveHome, configured.slice(2)) : configured; - return pathApi.isAbsolute(expanded) - ? pathApi.resolve(expanded) - : pathApi.resolve(input.cwd, expanded); + return NodePath.isAbsolute(expanded) + ? NodePath.resolve(expanded) + : NodePath.resolve(input.cwd, expanded); } function textGenerationError( @@ -243,14 +190,9 @@ export const makePrimeAgentTextGeneration = Effect.fn("makePrimeAgentTextGenerat const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig.ServerConfig; - const hostPlatform = yield* HostProcessPlatform; - const normalizedInputEnvironment = normalizePrimeAgentTextGenerationEnvironment( + const resolvedEnvironment = makePrimeAgentEnvironment( + primeAgentSettings, environment ?? process.env, - hostPlatform, - ); - const resolvedEnvironment = normalizePrimeAgentTextGenerationEnvironment( - makePrimeAgentEnvironment(primeAgentSettings, normalizedInputEnvironment), - hostPlatform, ); const helperEnvironment = Object.fromEntries( Object.entries(resolvedEnvironment).filter(([name]) => { @@ -387,7 +329,6 @@ export const makePrimeAgentTextGeneration = Effect.fn("makePrimeAgentTextGenerat const agentDir = resolvePrimeAgentTextGenerationHomePath({ environment: resolvedEnvironment, cwd: input.cwd, - platform: hostPlatform, }); const requestEnvironment = { ...helperEnvironment, diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index b79947839..4af30749c 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -7,6 +7,10 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { + findUnavailableProviderInstance, + providerUnavailableDetail, +} from "../provider/providerUnavailable.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; export type TextGenerationProvider = @@ -138,16 +142,22 @@ const resolveInstance = ( instanceId: ProviderInstanceId, ): Effect.Effect => registry.getInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance - ? Effect.succeed(instance) - : Effect.fail( + Effect.flatMap((instance) => { + if (instance) return Effect.succeed(instance); + return registry.listUnavailable.pipe( + Effect.flatMap((unavailable) => { + const shadow = findUnavailableProviderInstance(unavailable, instanceId); + return Effect.fail( new TextGenerationError({ operation, - detail: `No provider instance registered for id '${instanceId}'.`, + detail: shadow + ? providerUnavailableDetail(shadow) + : `No provider instance registered for id '${instanceId}'.`, }), - ), - ), + ); + }), + ); + }), ); export const makeTextGenerationFromRegistry = ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 163dc8833..a0ea6a9ca 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -24,6 +24,7 @@ import { ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, loadVideoPreviewUrl, + mergeFailedComposerSend, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, @@ -407,10 +408,10 @@ describe("buildThreadTurnInterruptInput", () => { ).toEqual({ threadId, turnId: activeTurnId }); }); - it("omits a turn id when the session is not running", () => { - expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ - threadId, - }); + it.each(["ready", "starting"] as const)("omits a turn id when the session is %s", (status) => { + expect( + buildThreadTurnInterruptInput(makeThread({ session: { ...readySession, status } })), + ).toEqual({ threadId }); }); }); @@ -500,6 +501,22 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("mergeFailedComposerSend", () => { + it("puts a failed immediate send before a newer draft and deduplicates attachments", () => { + expect( + mergeFailedComposerSend({ + failedText: "failed first", + currentText: "typed while sending", + failedAttachments: [{ id: "failed" }, { id: "shared", source: "failed" }], + currentAttachments: [{ id: "shared", source: "current" }, { id: "new" }], + }), + ).toEqual({ + text: "failed first\n\ntyped while sending", + attachments: [{ id: "failed" }, { id: "shared", source: "current" }, { id: "new" }], + }); + }); +}); + describe("getStartedThreadModelChangeBlockReason", () => { const providers = [ { @@ -589,7 +606,7 @@ describe("getStartedThreadModelChangeBlockReason", () => { ).toBeNull(); }); - it("blocks started-session model changes when either provider requires a new thread", () => { + it("blocks every cross-instance change after a session starts", () => { expect( getStartedThreadModelChangeBlockReason({ providers, @@ -604,11 +621,91 @@ describe("getStartedThreadModelChangeBlockReason", () => { }, }), ).toEqual({ - title: "Start a new chat to change models", + title: "Start a new chat to change providers", description: - "This provider does not allow switching models after a conversation has started.", + "A started thread stays bound to the exact provider account that created its session.", + }); + }); + + it("allows only available peers with the same exact continuation identity", () => { + const compatibleProviders = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + continuation: { groupKey: "codex:home:shared" }, + enabled: true, + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const }, + }, + { + instanceId: ProviderInstanceId.make("codex_personal"), + driver: ProviderDriverKind.make("codex"), + continuation: { groupKey: "codex:home:shared" }, + enabled: true, + installed: true, + status: "ready" as const, + auth: { status: "authenticated" as const }, + }, + ]; + const input = { + providers: compatibleProviders, + hasStartedSession: true, + currentProviderInstanceId: ProviderInstanceId.make("codex"), + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("codex_personal"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + } as const; + + expect(getStartedThreadModelChangeBlockReason(input)).toBeNull(); + expect( + getStartedThreadModelChangeBlockReason({ + ...input, + providers: compatibleProviders.map((provider) => + provider.instanceId === ProviderInstanceId.make("codex_personal") + ? { ...provider, availability: "unavailable" as const } + : provider, + ), + }), + ).toMatchObject({ + description: expect.stringContaining("unavailable"), }); }); + + it("lets a warning bound provider reconcile a stale picker selection", () => { + const providersWithWarningBinding = providers.map((provider) => + provider.instanceId === ProviderInstanceId.make("prime") + ? { + ...provider, + enabled: true, + installed: true, + status: "warning" as const, + auth: { status: "authenticated" as const }, + } + : provider, + ); + expect( + getStartedThreadModelChangeBlockReason({ + providers: providersWithWarningBinding, + hasStartedSession: true, + currentProviderInstanceId: ProviderInstanceId.make("prime"), + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("prime"), + model: "anthropic/claude-sonnet-4.5", + }, + }), + ).toBeNull(); + }); }); describe("resolveSendEnvMode", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index feda7e46a..dd0ce2b5a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -13,6 +13,7 @@ import { type TurnId, } from "@t3tools/contracts"; import { isPrimeAgentDefaultModelUnavailable } from "@t3tools/shared/model"; +import { getProviderAdmissionAvailability } from "@t3tools/client-runtime/providerAvailability"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, @@ -397,6 +398,29 @@ export function cloneComposerImageForRetry( } } +export function mergeFailedComposerSend(input: { + readonly failedText: string; + readonly currentText: string; + readonly failedAttachments: ReadonlyArray; + readonly currentAttachments: ReadonlyArray; +}): { readonly text: string; readonly attachments: T[] } { + const currentIds = new Set(input.currentAttachments.map((attachment) => attachment.id)); + return { + text: + input.failedText.length === 0 + ? input.currentText + : input.currentText.length === 0 + ? input.failedText + : `${input.failedText} + +${input.currentText}`, + attachments: [ + ...input.failedAttachments.filter((attachment) => !currentIds.has(attachment.id)), + ...input.currentAttachments, + ], + }; +} + export function deriveComposerSendState(options: { prompt: string; imageCount: number; @@ -547,7 +571,13 @@ export function deriveLockedProvider(input: { export function getStartedThreadModelChangeBlockReason(input: { providers: ReadonlyArray< - Pick + Pick & + Partial< + Pick< + ServerProvider, + "continuation" | "enabled" | "installed" | "auth" | "availability" | "status" + > + > >; hasStartedSession: boolean; currentModelSelection: ModelSelection; @@ -557,18 +587,51 @@ export function getStartedThreadModelChangeBlockReason(input: { if (!input.hasStartedSession) { return null; } - const currentModelSelection = { - ...input.currentModelSelection, - instanceId: input.currentProviderInstanceId ?? input.currentModelSelection.instanceId, - }; - if ( - currentModelSelection.instanceId === input.nextModelSelection.instanceId && - currentModelSelection.model === input.nextModelSelection.model - ) { + const currentInstanceId = + input.currentProviderInstanceId ?? input.currentModelSelection.instanceId; + if (input.nextModelSelection.instanceId !== currentInstanceId) { + const currentProvider = input.providers.find( + (provider) => provider.instanceId === currentInstanceId, + ); + const targetProvider = input.providers.find( + (provider) => provider.instanceId === input.nextModelSelection.instanceId, + ); + const currentContinuationKey = currentProvider?.continuation?.groupKey?.trim() ?? ""; + const targetContinuationKey = targetProvider?.continuation?.groupKey?.trim() ?? ""; + const targetAvailable = + targetProvider !== undefined && + getProviderAdmissionAvailability({ + provider: targetProvider, + instanceId: String(input.nextModelSelection.instanceId), + providerSnapshotKnown: true, + }).status === "available"; + if ( + currentProvider?.driver === targetProvider?.driver && + currentContinuationKey.length > 0 && + currentContinuationKey === targetContinuationKey && + targetAvailable + ) { + return null; + } + return { + title: "Start a new chat to change providers", + description: targetAvailable + ? "A started thread stays bound to the exact provider account that created its session." + : "The selected compatible provider account is unavailable on this environment.", + }; + } + // A stale device can observe the session binding before the exact persisted + // model arrives. Selecting an explicit model for that binding is remediation; + // never synthesize it by copying the old provider's model or options. + if (input.currentModelSelection.instanceId !== currentInstanceId) { + return null; + } + const currentModelSelection = input.currentModelSelection; + if (currentModelSelection.model === input.nextModelSelection.model) { return null; } const currentProvider = input.providers.find( - (snapshot) => snapshot.instanceId === currentModelSelection.instanceId, + (snapshot) => snapshot.instanceId === currentInstanceId, ); const nextProvider = input.providers.find( (snapshot) => snapshot.instanceId === input.nextModelSelection.instanceId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a358df41e..6c760a0ec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,6 +86,7 @@ import { import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; +import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; import { isAtomCommandInterrupted, mapAtomCommandResult, @@ -224,14 +225,17 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, sortProviderInstanceEntries, } from "../providerInstances"; -import { resolveComposerInstanceSelection } from "../composerInstanceSelection"; +import { + canStartComposerTurn, + resolveComposerInstanceSelection, +} from "../composerInstanceSelection"; import { buildThreadHandoffSeed, getThreadContinuationLinks, @@ -242,6 +246,7 @@ import { ThreadContinuationBanner } from "./chat/ThreadContinuationBanner"; import { deriveComposerUsage } from "../providerUsageAccounts"; import { usageStaleAfterMs } from "./providerUsage/ProviderUsageMatrix.logic"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { useCheckpointDiff } from "../lib/checkpointDiffState"; import { useClientSettings, @@ -274,6 +279,7 @@ import { type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, + flushComposerDraftStore, markPromotedDraftThreadByRef, useComposerDraftStore, useEffectiveComposerModelState, @@ -404,6 +410,7 @@ import { deriveLockedProvider, readFileAsDataUrl, loadVideoPreviewUrl, + mergeFailedComposerSend, isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, @@ -1365,12 +1372,6 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); - const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { - reportFailure: false, - }); - const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { - reportFailure: false, - }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const followUpThreadInputQueue = useAtomCommand(threadEnvironment.followUpInputQueue, { reportFailure: false, @@ -1542,6 +1543,12 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setInteractionMode, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const transferComposerContentSnapshotForProviderConflict = useComposerDraftStore( + (store) => store.transferComposerContentSnapshotForProviderConflict, + ); + const continueComposerOnBoundProvider = useComposerDraftStore( + (store) => store.continueOnBoundProvider, + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, @@ -2480,11 +2487,35 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider, + const threadHandoffEntries = useMemo( + () => applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + [providerStatuses, settings], ); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const composerInstanceEntries = useMemo( + () => sortProviderInstanceEntries(threadHandoffEntries), + [threadHandoffEntries], + ); + const composerInstanceSelection = useMemo( + () => + resolveComposerInstanceSelection({ + entries: composerInstanceEntries, + draftActiveProvider: composerActiveProvider, + sessionInstanceId: activeThread?.session?.providerInstanceId, + threadInstanceId: activeThread?.modelSelection?.instanceId, + projectInstanceId: activeProject?.defaultModelSelection?.instanceId, + lockedProvider, + nowMs: Date.now(), + }), + [ + activeProject?.defaultModelSelection?.instanceId, + activeThread?.modelSelection?.instanceId, + activeThread?.session?.providerInstanceId, + composerActiveProvider, + composerInstanceEntries, + lockedProvider, + ], + ); + const selectedProvider: ProviderDriverKind = composerInstanceSelection.driverKind; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null; @@ -2571,6 +2602,9 @@ function ChatViewContent(props: ChatViewProps) { if (instanceId === undefined) return null; return serverConfig?.providers.find((provider) => provider.instanceId === instanceId) ?? null; }, [activeThread?.session?.providerInstanceId, serverConfig?.providers]); + const sessionProviderAdmissionAvailable = + activeThread?.session?.providerInstanceId !== undefined && + canStartComposerTurn(composerInstanceSelection); const quickQuestionAvailable = canAskSessionSideQuestion( activeSessionProviderStatus, activeEnvironmentConnectionPhase === "connected" ? "connected" : "available", @@ -3178,15 +3212,12 @@ function ChatViewContent(props: ChatViewProps) { }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - // Prefer an instance-id match so a custom Codex instance (e.g. - // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. - const selectedProviderInstanceId = - providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) - ?.instanceId ?? null; + // The banner and adjacent provider controls must describe the exact entry + // the composer resolved. In particular, an unavailable stored preference + // remains the active entry while turn admission is blocked; it must not be + // replaced by a ready account behind the banner. const activeProviderInstanceId = - selectedProviderInstanceId ?? + composerInstanceSelection.entry?.instanceId ?? activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? @@ -3223,6 +3254,46 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const providerAdmissionDisabledReason = useMemo(() => { + if (activeEnvironmentUnavailable) return "Environment disconnected"; + if (isLocalDraftThread && activeProject === null) return "Choose a project before sending"; + const providerUnavailableReason = getProviderAdmissionUnavailableReason({ + provider: activeProviderStatus, + instanceId: activeProviderInstanceId ?? undefined, + providerSnapshotKnown: serverConfig !== null, + }); + if (providerUnavailableReason) return providerUnavailableReason; + if (composerInstanceSelection.entry === undefined) { + const boundInstanceId = activeThread?.session?.providerInstanceId; + return boundInstanceId + ? `The thread's provider binding '${boundInstanceId}' cannot be resolved on this environment.` + : "No provider is available on this environment."; + } + if (!composerInstanceSelection.entry.enabled) { + return `${composerInstanceSelection.entry.displayName} is disabled in provider settings.`; + } + if (!sessionProviderAdmissionAvailable && activeThread?.session?.providerInstanceId) { + const transition = resolveProviderContinuationTransition({ + providers: providerStatuses, + currentInstanceId: activeThread.session.providerInstanceId, + targetInstanceId: composerInstanceSelection.instanceId, + }); + if (!transition.compatible) return transition.reason; + return "The selected model does not belong to an available compatible provider instance."; + } + return null; + }, [ + activeEnvironmentUnavailable, + activeProject, + activeProviderInstanceId, + activeProviderStatus, + activeThread?.session?.providerInstanceId, + composerInstanceSelection, + isLocalDraftThread, + providerStatuses, + serverConfig, + sessionProviderAdmissionAvailable, + ]); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = useLocalStorage( `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, @@ -4261,78 +4332,32 @@ function ChatViewContent(props: ChatViewProps) { const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; - createdAt: string; - modelSelection?: ModelSelection; branch?: string; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; }): Promise> => { - if (!serverThread) { + if (!serverThread || input.branch === undefined) { return AsyncResult.success(undefined); } - let result: AtomCommandResult = AsyncResult.success(undefined); const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: serverThread.modelSelection, - ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}), currentBranch: serverThread.branch, - ...(input.branch ? { nextBranch: input.branch } : {}), + nextBranch: input.branch, }); - if (metadataUpdate) { - result = mapAtomCommandResult( - await updateThreadMetadata({ - environmentId, - input: { - threadId: input.threadId, - ...metadataUpdate, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } - } - - if (input.runtimeMode !== serverThread.runtimeMode) { - result = mapAtomCommandResult( - await setThreadRuntimeMode({ - environmentId, - input: { - threadId: input.threadId, - runtimeMode: input.runtimeMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } - } - - if (input.interactionMode !== serverThread.interactionMode) { - result = mapAtomCommandResult( - await setThreadInteractionMode({ - environmentId, - input: { - threadId: input.threadId, - interactionMode: input.interactionMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); + if (!metadataUpdate) { + return AsyncResult.success(undefined); } - return result; + return mapAtomCommandResult( + await updateThreadMetadata({ + environmentId, + input: { + threadId: input.threadId, + ...metadataUpdate, + }, + }), + () => undefined, + ); }, - [ - environmentId, - serverThread, - setThreadInteractionMode, - setThreadRuntimeMode, - updateThreadMetadata, - ], + [environmentId, serverThread, updateThreadMetadata], ); // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during @@ -4996,10 +5021,6 @@ function ChatViewContent(props: ChatViewProps) { // drained one can only continue as a fresh thread elsewhere. The offer is // resolved here, next to the thread-creation path that acts on it, and the // composer only renders it. - const threadHandoffEntries = useMemo( - () => applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), - [providerStatuses, settings], - ); // Same snapshot the resume-compaction banner reads; derived once. const threadHandoffContextWindow = activeContextWindow; const threadHandoffOffer = useMemo(() => { @@ -5046,35 +5067,10 @@ function ChatViewContent(props: ChatViewProps) { [threadHandoffDiff.data?.diff], ); const [isContinuingThreadOnAccount, setIsContinuingThreadOnAccount] = useState(false); - // Capacity for the account the composer will actually send to, resolved the - // same way the composer resolves it — picker choice, session binding, drain - // routing and all — so the strip never names one account while the send - // goes to another. For a Prime Agent thread the selected model decides - // whose capacity applies. - const composerInstanceEntries = useMemo( - () => sortProviderInstanceEntries(threadHandoffEntries), - [threadHandoffEntries], - ); - const composerInstanceSelection = useMemo( - () => - resolveComposerInstanceSelection({ - entries: composerInstanceEntries, - draftActiveProvider: composerActiveProvider, - sessionInstanceId: activeThread?.session?.providerInstanceId, - threadInstanceId: activeThread?.modelSelection?.instanceId, - projectInstanceId: activeProject?.defaultModelSelection?.instanceId, - lockedProvider, - nowMs: Date.now(), - }), - [ - activeProject?.defaultModelSelection?.instanceId, - activeThread?.modelSelection?.instanceId, - activeThread?.session?.providerInstanceId, - composerActiveProvider, - composerInstanceEntries, - lockedProvider, - ], - ); + const [isStartingProviderConflictThread, setIsStartingProviderConflictThread] = useState(false); + // Capacity for the account the composer will actually send to uses the + // shared selection above, so the strip never names one account while the + // composer is blocked on or sends to another. const { selectedModel: composerSelectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, providers: providerStatuses, @@ -6011,7 +6007,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || sendCtx.selectedModelSelection === null) { notifyDirectAnnotationAttached(); return; } @@ -6516,13 +6512,9 @@ function ChatViewContent(props: ChatViewProps) { if (failure === null && delivery === "immediate" && isServerThread) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode, }); if (settingsResult._tag === "Failure") { failure = settingsResult; @@ -6685,87 +6677,65 @@ function ChatViewContent(props: ChatViewProps) { const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); - if (delivery === "follow-up") { - removeOptimisticMessage(); - const currentPrompt = promptRef.current; - const mergedPrompt = - promptForSend.length === 0 - ? currentPrompt - : currentPrompt.length === 0 - ? promptForSend - : `${promptForSend}\n\n${currentPrompt}`; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - const mergeById = ( - sent: ReadonlyArray, - current: ReadonlyArray, - ): T[] => { - const currentIds = new Set(current.map((item) => item.id)); - return [...sent.filter((item) => !currentIds.has(item.id)), ...current]; - }; - const currentDraft = - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ?? null; - const mergedTerminalContexts = mergeById( - composerTerminalContextsSnapshot, - composerTerminalContextsRef.current, - ); - const mergedElementContexts = mergeById( - composerElementContextsSnapshot, - composerElementContextsRef.current, - ); - const mergedPreviewAnnotations = mergeById( - composerPreviewAnnotationsSnapshot, - currentDraft?.previewAnnotations ?? [], - ); - const mergedReviewComments = mergeById( - composerReviewCommentsSnapshot, - currentDraft?.reviewComments ?? [], - ); - promptRef.current = mergedPrompt; - composerImagesRef.current = [...composerImagesRef.current, ...retryComposerImages]; - composerTerminalContextsRef.current = mergedTerminalContexts; - composerElementContextsRef.current = mergedElementContexts; - setComposerDraftPrompt(composerDraftTarget, mergedPrompt); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - setComposerDraftTerminalContexts(composerDraftTarget, mergedTerminalContexts); - setComposerDraftElementContexts(composerDraftTarget, mergedElementContexts); - setComposerDraftPreviewAnnotations(composerDraftTarget, mergedPreviewAnnotations); - setComposerDraftReviewComments(composerDraftTarget, mergedReviewComments); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(mergedPrompt, mergedPrompt.length), - prompt: mergedPrompt, - detectTrigger: true, - }); - } else if ( - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - composerElementContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 - ) { - removeOptimisticMessage(); - promptRef.current = promptForSend; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - composerImagesRef.current = retryComposerImages; - composerFilesRef.current = composerFilesSnapshot; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - composerElementContextsRef.current = composerElementContextsSnapshot; - setComposerDraftPrompt(composerDraftTarget, promptForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), - prompt: promptForSend, - detectTrigger: true, - }); - } + removeOptimisticMessage(); + const currentDraft = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ?? null; + const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); + const mergedPromptAndImages = mergeFailedComposerSend({ + failedText: promptForSend, + currentText: promptRef.current, + failedAttachments: retryComposerImages, + currentAttachments: composerImagesRef.current, + }); + const mergedFiles = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerFilesSnapshot, + currentAttachments: composerFilesRef.current, + }).attachments; + const mergedTerminalContexts = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerTerminalContextsSnapshot, + currentAttachments: composerTerminalContextsRef.current, + }).attachments; + const mergedElementContexts = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerElementContextsSnapshot, + currentAttachments: composerElementContextsRef.current, + }).attachments; + const mergedPreviewAnnotations = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerPreviewAnnotationsSnapshot, + currentAttachments: currentDraft?.previewAnnotations ?? [], + }).attachments; + const mergedReviewComments = mergeFailedComposerSend({ + failedText: "", + currentText: "", + failedAttachments: composerReviewCommentsSnapshot, + currentAttachments: currentDraft?.reviewComments ?? [], + }).attachments; + const mergedPrompt = mergedPromptAndImages.text; + promptRef.current = mergedPrompt; + composerImagesRef.current = mergedPromptAndImages.attachments; + composerFilesRef.current = mergedFiles; + composerTerminalContextsRef.current = mergedTerminalContexts; + composerElementContextsRef.current = mergedElementContexts; + clearComposerDraftContent(composerDraftTarget); + setComposerDraftPrompt(composerDraftTarget, mergedPrompt); + addComposerDraftImages(composerDraftTarget, mergedPromptAndImages.attachments); + addComposerDraftFiles(composerDraftTarget, mergedFiles); + setComposerDraftTerminalContexts(composerDraftTarget, mergedTerminalContexts); + setComposerDraftElementContexts(composerDraftTarget, mergedElementContexts); + setComposerDraftPreviewAnnotations(composerDraftTarget, mergedPreviewAnnotations); + setComposerDraftReviewComments(composerDraftTarget, mergedReviewComments); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(mergedPrompt, mergedPrompt.length), + prompt: mergedPrompt, + detectTrigger: true, + }); if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { @@ -6898,12 +6868,18 @@ function ChatViewContent(props: ChatViewProps) { ); const onCompactSession = useCallback( () => - activeThreadId + activeThreadId && sessionProviderAdmissionAvailable ? runSessionCompactionCommand(() => compactThreadSession({ environmentId, input: { threadId: activeThreadId } }), ) : Promise.resolve(null), - [activeThreadId, compactThreadSession, environmentId, runSessionCompactionCommand], + [ + activeThreadId, + compactThreadSession, + environmentId, + runSessionCompactionCommand, + sessionProviderAdmissionAvailable, + ], ); const onAbortSessionCompaction = useCallback( () => @@ -6919,7 +6895,7 @@ function ChatViewContent(props: ChatViewProps) { ); const onSetSessionAutoCompaction = useCallback( (enabled: boolean) => - activeThreadId + activeThreadId && sessionProviderAdmissionAvailable ? runSessionCompactionCommand(() => setThreadSessionAutoCompaction({ environmentId, @@ -6927,7 +6903,13 @@ function ChatViewContent(props: ChatViewProps) { }), ) : Promise.resolve(null), - [activeThreadId, environmentId, runSessionCompactionCommand, setThreadSessionAutoCompaction], + [ + activeThreadId, + environmentId, + runSessionCompactionCommand, + sessionProviderAdmissionAvailable, + setThreadSessionAutoCompaction, + ], ); const onRefineSessionHarness = @@ -7115,7 +7097,7 @@ function ChatViewContent(props: ChatViewProps) { const onSetSessionInputQueueMode = useCallback( async (queue: "steering" | "follow-up", mode: "all-at-once" | "one-at-a-time") => { - if (!activeThreadId) return; + if (!activeThreadId || !sessionProviderAdmissionAvailable) return; const result = await setThreadSessionInputQueueMode({ environmentId, input: { threadId: activeThreadId, queue, mode }, @@ -7132,7 +7114,13 @@ function ChatViewContent(props: ChatViewProps) { title: `${queue === "steering" ? "Steering" : "Follow-up"} delivery updated`, }); }, - [activeThreadId, environmentId, setThreadError, setThreadSessionInputQueueMode], + [ + activeThreadId, + environmentId, + sessionProviderAdmissionAvailable, + setThreadError, + setThreadSessionInputQueueMode, + ], ); const onRespondToApproval = useCallback( @@ -7380,7 +7368,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || sendCtx.selectedModelSelection === null) { return; } const { @@ -7423,13 +7411,9 @@ function ChatViewContent(props: ChatViewProps) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, - createdAt: messageCreatedAt, - modelSelection: ctxSelectedModelSelection, ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode: nextInteractionMode, }); let failure: AtomCommandResult | null = settingsResult._tag === "Failure" ? settingsResult : null; @@ -7525,7 +7509,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || sendCtx.selectedModelSelection === null) { return; } const { @@ -7670,6 +7654,98 @@ function ChatViewContent(props: ChatViewProps) { composerRef, ]); + const onContinueProviderBindingConflict = useCallback(() => { + if (!activeThread || !isServerThread) return; + const conflict = useComposerDraftStore + .getState() + .getComposerDraft(composerDraftTarget)?.providerBindingConflict; + if (!conflict || activeThread.modelSelection.instanceId !== conflict.boundInstanceId) { + return; + } + continueComposerOnBoundProvider(composerDraftTarget, activeThread.modelSelection); + scheduleComposerFocus(); + }, [ + activeThread, + composerDraftTarget, + continueComposerOnBoundProvider, + isServerThread, + scheduleComposerFocus, + ]); + + const onStartProviderBindingConflictThread = useCallback(async () => { + if ( + !activeThread || + !activeThreadRef || + !activeProject || + !isServerThread || + isStartingProviderConflictThread + ) { + return; + } + const conflict = useComposerDraftStore + .getState() + .getComposerDraft(composerDraftTarget)?.providerBindingConflict; + if (!conflict) return; + + setIsStartingProviderConflictThread(true); + const nextDraftId = newDraftId(); + const nextThreadId = newThreadId(); + const createdAt = new Date().toISOString(); + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); + const nextRuntimeMode = conflict.runtimeMode ?? activeThread.runtimeMode; + const nextInteractionMode = conflict.interactionMode ?? activeThread.interactionMode; + + try { + // Every state write lands before navigation. A navigation failure leaves + // a normal durable sidebar draft instead of splitting its content from + // the original provider/account selection. + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { + threadId: nextThreadId, + createdAt, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + envMode: activeThread.worktreePath === null ? "local" : "worktree", + startFromOrigin: false, + runtimeMode: nextRuntimeMode, + interactionMode: nextInteractionMode, + }); + transferComposerContentSnapshotForProviderConflict(activeThreadRef, nextDraftId); + flushComposerDraftStore(); + await navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(nextDraftId), + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Draft moved to a new thread", + description: + error instanceof Error + ? `Could not open it automatically: ${error.message}` + : "Could not open it automatically. The draft remains in the sidebar.", + }), + ); + } finally { + setIsStartingProviderConflictThread(false); + } + }, [ + activeProject, + activeThread, + activeThreadRef, + composerDraftTarget, + isServerThread, + isStartingProviderConflictThread, + navigate, + projectGroupingSettings, + setLogicalProjectDraftThreadId, + transferComposerContentSnapshotForProviderConflict, + ]); + // Continue a spent thread's work on another account. // // Only ever runs from the composer's handoff tab. Nothing switches accounts @@ -7716,11 +7792,7 @@ function ChatViewContent(props: ChatViewProps) { sendCtx.selectedModel, ); if (targetModel === null) return; - const nextThreadModelSelection: ModelSelection = { - ...sendCtx.selectedModelSelection, - instanceId: offer.targetInstanceId, - model: targetModel, - }; + const nextThreadModelSelection = createModelSelection(offer.targetInstanceId, targetModel); const targetProviderModels = providerStatuses.find((provider) => provider.instanceId === offer.targetInstanceId)?.models ?? sendCtx.selectedProviderModels; @@ -7894,14 +7966,17 @@ function ChatViewContent(props: ChatViewProps) { return; } if (lockedProvider !== null && activeThread.session?.providerInstanceId) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { + const transition = resolveProviderContinuationTransition({ + providers: providerStatuses, + currentInstanceId: activeThread.session.providerInstanceId, + targetInstanceId: instanceId, + }); + if (!transition.compatible) { + toastManager.add({ + type: "warning", + title: "This account cannot continue the thread", + description: transition.reason, + }); scheduleComposerFocus(); return; } @@ -8463,7 +8538,7 @@ function ChatViewContent(props: ChatViewProps) { ? "Messages loading" : activeSessionInteraction ? "Resolve the session request to continue" - : null + : providerAdmissionDisabledReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} @@ -8525,7 +8600,12 @@ function ChatViewContent(props: ChatViewProps) { onImplementPlanInNewThread={onImplementPlanInNewThread} threadHandoffOffer={threadHandoffOffer} isContinuingThreadOnAccount={isContinuingThreadOnAccount} + isStartingProviderConflictThread={isStartingProviderConflictThread} onContinueThreadOnAccount={onContinueThreadOnAccount} + onContinueProviderBindingConflict={onContinueProviderBindingConflict} + onStartProviderBindingConflictThread={ + onStartProviderBindingConflictThread + } onRespondToApproval={onRespondToApproval} onSelectActivePendingUserInputOption={ onSelectActivePendingUserInputOption diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7d5416f2f..b9dcb21eb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -25,6 +25,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { canAbortSessionCompaction, canConfigureSessionAutoCompaction, @@ -98,6 +99,7 @@ import { type PersistedComposerImageAttachment, composerFileDedupKey, composerFileMatchesReattachMarker, + composerDraftHasUserContent, composerFileNeedsReattach, composerTargetKey, hydrateImagesFromPersisted, @@ -416,7 +418,10 @@ import { SESSION_HARNESS_REFINEMENT_CONFIRMATION, } from "../../sessionHarnessRefinement"; import { getProviderInteractionModeToggle } from "../../providerModels"; -import { resolveComposerInstanceSelection } from "../../composerInstanceSelection"; +import { + canStartComposerTurn, + resolveComposerInstanceSelection, +} from "../../composerInstanceSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -443,6 +448,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; import type { ThreadHandoffOffer } from "./ThreadHandoff.logic"; import { ThreadHandoffTab } from "./ThreadHandoffTab"; +import { ProviderBindingConflictNotice } from "./ProviderBindingConflictNotice"; import { QuickQuestionDialog } from "./QuickQuestionDialog"; import { SessionResourcesDialog } from "./SessionResourcesDialog"; @@ -626,6 +632,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isComplete: boolean; } | null; isRunning: boolean; + isStopCapable: boolean; canQueueFollowUp: boolean; onQueueFollowUp: () => void; showPlanFollowUpPrompt: boolean; @@ -660,6 +667,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compact={props.compact} pendingAction={props.pendingAction} isRunning={props.isRunning} + isStopCapable={props.isStopCapable} canQueueFollowUp={props.canQueueFollowUp} onQueueFollowUp={props.onQueueFollowUp} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} @@ -722,7 +730,7 @@ export interface ChatComposerHandle { reviewComments: ReviewCommentContext[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; - selectedModelSelection: ModelSelection; + selectedModelSelection: ModelSelection | null; providerAvailable: boolean; selectedProvider: ProviderDriverKind; selectedModel: string; @@ -831,6 +839,7 @@ export interface ChatComposerProps { // creation the offer leads to; the composer only shows it. threadHandoffOffer: ThreadHandoffOffer | null; isContinuingThreadOnAccount: boolean; + isStartingProviderConflictThread: boolean; // Callbacks onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; @@ -860,6 +869,8 @@ export interface ChatComposerProps { ) => Promise; onImplementPlanInNewThread: () => void; onContinueThreadOnAccount: () => void; + onContinueProviderBindingConflict: () => void; + onStartProviderBindingConflictThread: () => void; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -951,6 +962,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, threadHandoffOffer, isContinuingThreadOnAccount, + isStartingProviderConflictThread, onSend, onQueueFollowUp, onClearSessionInputQueue, @@ -968,6 +980,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onCancelQuickQuestion, onImplementPlanInNewThread, onContinueThreadOnAccount, + onContinueProviderBindingConflict, + onStartProviderBindingConflictThread, onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, @@ -993,6 +1007,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); + const setComposerProviderBindingConflict = useComposerDraftStore( + (state) => state.setProviderBindingConflict, + ); // Live target key, for async flows that must notice a thread switch that // happened while they awaited. const composerDraftTargetKeyRef = useRef(""); @@ -1199,6 +1216,71 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // same driver kind. const selectedProviderEntry = composerSelection.entry; const noProviderAvailable = selectedProviderEntry === undefined; + const providerSelectionBlocked = composerSelection.blockedByUnavailablePreference; + const activeSessionInstanceId = activeThread?.session?.providerInstanceId; + const providerBindingConflict = composerDraft.providerBindingConflict; + const draftSelectionForSelectedInstance = + composerDraft.modelSelectionByProvider[selectedInstanceId]; + const selectedInstanceTransition = activeSessionInstanceId + ? resolveProviderContinuationTransition({ + providers: providerStatuses, + currentInstanceId: activeSessionInstanceId, + targetInstanceId: selectedInstanceId, + }) + : ({ compatible: true } as const); + const hasSelectionOwnedBySelectedInstance = + activeSessionInstanceId === undefined || + activeThreadModelSelection?.instanceId === selectedInstanceId || + (composerDraft.activeProvider === selectedInstanceId && + draftSelectionForSelectedInstance?.instanceId === selectedInstanceId); + const shouldBlockProviderBindingConflict = + composerSelection.draftConflictsWithSessionBinding && + composerDraft.modelSelectionExplicit === true && + composerDraftHasUserContent(composerDraft); + const hasPendingProviderBindingConflict = + providerBindingConflict !== undefined || shouldBlockProviderBindingConflict; + const boundProviderSelectionReason = hasPendingProviderBindingConflict + ? "Choose whether this unsent message stays with its selected provider or this thread" + : activeSessionInstanceId !== undefined && !hasSelectionOwnedBySelectedInstance + ? "Select a model for the thread’s bound provider" + : null; + const providerTurnUnavailable = + !canStartComposerTurn(composerSelection) || + !selectedInstanceTransition.compatible || + !hasSelectionOwnedBySelectedInstance || + hasPendingProviderBindingConflict; + useEffect(() => { + setComposerProviderBindingConflict( + composerDraftTarget, + activeSessionInstanceId !== undefined && shouldBlockProviderBindingConflict + ? activeSessionInstanceId + : null, + ); + }, [ + activeSessionInstanceId, + composerDraftTarget, + setComposerProviderBindingConflict, + shouldBlockProviderBindingConflict, + ]); + const conflictOriginalEntry = providerBindingConflict + ? providerInstanceEntries.find( + (entry) => entry.instanceId === providerBindingConflict.originalSelection.instanceId, + ) + : undefined; + const conflictBoundEntry = providerBindingConflict + ? providerInstanceEntries.find( + (entry) => entry.instanceId === providerBindingConflict.boundInstanceId, + ) + : undefined; + const conflictOriginalProviderName = + conflictOriginalEntry?.displayName ?? + providerBindingConflict?.originalSelection.instanceId ?? + ""; + const conflictBoundProviderName = + conflictBoundEntry?.displayName ?? providerBindingConflict?.boundInstanceId ?? ""; + const canContinueProviderBindingConflict = + providerBindingConflict !== undefined && + activeThreadModelSelection?.instanceId === providerBindingConflict.boundInstanceId; // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. @@ -1254,7 +1336,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsReloadingSessionResources(false); } }, [isReloadingSessionResources, onReloadSessionResources, sessionResourceReloadDisabled]); - const activeSessionInstanceId = activeThread?.session?.providerInstanceId; const sessionInputQueue = useMemo( () => activeSessionInstanceId === undefined @@ -1272,6 +1353,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasSessionInputQueueModes(sessionInputQueue) && supportsSessionInputQueueSetModes(activeSessionProviderStatus); const canQueueSessionFollowUp = + !providerTurnUnavailable && activeThread?.session?.status === "running" && phase === "running" && supportsSessionInputQueueFollowUp(activeSessionProviderStatus); @@ -1350,6 +1432,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) settingSessionInputQueueMode?.scopeKey === sessionInputQueueScopeKey; const canSetSessionInputQueueModes = showSessionInputQueueModes && + !providerTurnUnavailable && (activeThread?.session?.status === "ready" || activeThread?.session?.status === "running") && !isConnecting && environmentUnavailable === null && @@ -1471,6 +1554,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sessionCompactionMutation.action === "compact", }); const sendDisabledReason = + boundProviderSelectionReason ?? baseSendDisabledReason ?? (sessionCompactionBlocksSubmission ? "Context compaction in progress" : null); const isSendDisabled = sendDisabledReason !== null; @@ -1568,6 +1652,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : null, canCompact: contextCompactionConnected && + !providerTurnUnavailable && sessionCompactionMutation === null && canStartSessionCompaction(activeSessionProviderStatus, sessionCompaction), canAbort: @@ -1576,11 +1661,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) canAbortSessionCompaction(activeSessionProviderStatus, sessionCompaction), canSetAuto: contextCompactionConnected && + !providerTurnUnavailable && sessionCompactionMutation === null && canConfigureSessionAutoCompaction(activeSessionProviderStatus, sessionCompaction), onCompact: () => { if ( !contextCompactionConnected || + providerTurnUnavailable || !canStartSessionCompaction(activeSessionProviderStatus, sessionCompaction) ) { return; @@ -1599,6 +1686,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onSetAuto: (enabled: boolean) => { if ( !contextCompactionConnected || + providerTurnUnavailable || !canConfigureSessionAutoCompaction(activeSessionProviderStatus, sessionCompaction) ) { return; @@ -1815,9 +1903,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) handleRuntimeModeChange(resolvedRuntimeMode); } }, [handleRuntimeModeChange, resolvedRuntimeMode, runtimeMode]); - const selectedModelSelection = useMemo( - () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), - [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], + const selectedModelSelection = useMemo( + () => + hasSelectionOwnedBySelectedInstance + ? createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch) + : null, + [ + hasSelectionOwnedBySelectedInstance, + selectedInstanceId, + selectedModel, + selectedModelOptionsForDispatch, + ], ); const selectedModelForPicker = selectedModel; // Instance-keyed option list so the picker can show each configured @@ -2199,11 +2295,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isSendDisabled || isConnecting || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired || environmentUnavailable !== null || !composerSendState.hasSendableContent; - const collapsedComposerPrimaryActionLabel = "Send message"; + const collapsedComposerPrimaryActionLabel = + sendDisabledReason ?? + (environmentUnavailable !== null + ? "Environment disconnected" + : projectSelectionRequired + ? "Choose a project before sending" + : providerTurnUnavailable + ? "The selected provider is unavailable for this thread" + : "Send message"); const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -2793,7 +2897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isSendDisabled || isConnecting || - noProviderAvailable || + providerTurnUnavailable || environmentUnavailable !== null || phase === "running" ) { @@ -2812,14 +2916,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isMobileViewport, isSendBusy, isSendDisabled, - noProviderAvailable, + providerTurnUnavailable, phase, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => { - if (noProviderAvailable || isSendDisabled) { + if (providerTurnUnavailable || isSendDisabled) { event?.preventDefault(); return; } @@ -2859,7 +2963,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, blurMobileComposerAfterSend, isSendDisabled, - noProviderAvailable, + providerTurnUnavailable, onSend, promptRef, shouldBlurMobileComposerOnSubmit, @@ -2868,7 +2972,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const compactThreadContext = useCallback(() => { if ( compactDisabled || - noProviderAvailable || + providerTurnUnavailable || composerSendState.hasSendableContent || activePendingApproval !== null || pendingUserInputs.length > 0 || @@ -2910,7 +3014,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerSendState.hasSendableContent, isConnecting, isSendBusy, - noProviderAvailable, + providerTurnUnavailable, pendingUserInputs.length, phase, promptRef, @@ -4095,7 +4199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, - providerAvailable: !noProviderAvailable, + providerAvailable: !providerTurnUnavailable, selectedProvider, selectedModel, selectedProviderModels, @@ -4138,7 +4242,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, - noProviderAvailable, + providerTurnUnavailable, selectedPromptEffort, selectedProvider, selectedProviderModels, @@ -4188,6 +4292,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onContinue={onContinueThreadOnAccount} isBusy={isContinuingThreadOnAccount} /> + {providerBindingConflict ? ( + + ) : null} undefined} showPlanFollowUpPrompt={false} @@ -4303,7 +4418,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired } isPreparingWorktree={false} @@ -4390,7 +4505,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? activePendingProgress.customAnswer || "Type your own answer, or leave this blank to use the selected option" : prompt.trim() || - (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} + (providerSelectionBlocked + ? "Select another provider to send" + : noProviderAvailable + ? "Enable a provider in Settings" + : "Ask anything...")} {inlineTasksBadge} {inlineStashBadge} @@ -4837,11 +4956,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired ? "Choose a project above to start a thread" - : noProviderAvailable - ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? DISCONNECTED_COMPOSER_PLACEHOLDER - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : providerSelectionBlocked + ? "Select another provider to send a message" + : noProviderAvailable + ? "Enable a provider in Settings to send a message" + : phase === "disconnected" + ? DISCONNECTED_COMPOSER_PLACEHOLDER + : "Ask anything, @tag files/folders, $use skills, or / for commands" } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> @@ -4856,6 +4977,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact pendingAction={pendingPrimaryAction} isRunning={false} + isStopCapable={activeThread?.session?.status === "starting"} canQueueFollowUp={false} onQueueFollowUp={() => undefined} showPlanFollowUpPrompt={false} @@ -4865,7 +4987,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired } isPreparingWorktree={false} @@ -4883,6 +5005,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) + {boundProviderSelectionReason ? ( +
+ {boundProviderSelectionReason} +
+ ) : null} {/* Bottom toolbar */} {isComposerCollapsedMobile || isComposerApprovalState ? null : ( @@ -4916,6 +5047,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} lockedContinuationGroupKey={lockedContinuationGroupKey} + lockedInstanceId={activeSessionInstanceId ?? null} instanceEntries={providerInstanceEntries} keybindings={keybindings} modelOptionsByInstance={modelOptionsByInstance} @@ -5101,6 +5233,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sessionGoal={sessionGoal} pendingAction={pendingPrimaryAction} isRunning={phase === "running"} + isStopCapable={ + phase === "running" || activeThread?.session?.status === "starting" + } canQueueFollowUp={canQueueSessionFollowUp} onQueueFollowUp={onQueueFollowUp} showPlanFollowUpPrompt={ @@ -5112,7 +5247,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + providerTurnUnavailable || projectSelectionRequired } isPreparingWorktree={isPreparingWorktree} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index c60099076..b3d52a86c 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -46,19 +46,28 @@ function renderPendingActions(isRunning: boolean) { ); } -function renderStandaloneStop() { +function renderStandaloneStop({ + isRunning = true, + isStopCapable, + isConnecting = false, +}: { + isRunning?: boolean; + isStopCapable?: boolean; + isConnecting?: boolean; +} = {}) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { compact: true, pendingAction: null, - isRunning: true, + isRunning, + ...(isStopCapable === undefined ? {} : { isStopCapable }), canQueueFollowUp: false, onQueueFollowUp: () => {}, showPlanFollowUpPrompt: false, promptHasText: false, isSendBusy: false, sendDisabledReason: null, - isConnecting: false, + isConnecting, isEnvironmentUnavailable: false, isPreparingWorktree: false, hasSendableContent: false, @@ -98,6 +107,8 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: function renderQueueCapableRunningActions( showSendWhileRunning: boolean, hasSendableContent: boolean, + sendDisabledReason: string | null = null, + isEnvironmentUnavailable = false, ) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { @@ -109,9 +120,9 @@ function renderQueueCapableRunningActions( showPlanFollowUpPrompt: false, promptHasText: hasSendableContent, isSendBusy: false, - sendDisabledReason: null, + sendDisabledReason, isConnecting: false, - isEnvironmentUnavailable: false, + isEnvironmentUnavailable, isPreparingWorktree: false, hasSendableContent, showSendWhileRunning, @@ -122,7 +133,10 @@ function renderQueueCapableRunningActions( ); } -function renderSendButton(sendDisabledReason: string | null = null) { +function renderSendButton( + sendDisabledReason: string | null = null, + isEnvironmentUnavailable = false, +) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { compact: true, @@ -135,7 +149,7 @@ function renderSendButton(sendDisabledReason: string | null = null) { isSendBusy: false, sendDisabledReason, isConnecting: false, - isEnvironmentUnavailable: false, + isEnvironmentUnavailable, isPreparingWorktree: false, hasSendableContent: true, onPreviousPendingQuestion: () => {}, @@ -248,6 +262,28 @@ describe("ComposerPrimaryActions", () => { expect(markup).toContain('aria-label="Sending feedback"'); }); + it("keeps a concrete provider reason when the aggregate unavailable flag is also set", () => { + const reason = "Select a model for the thread’s bound provider"; + const sendMarkup = renderSendButton(reason, true); + const queueMarkup = renderQueueCapableRunningActions(true, true, reason, true); + + expect(sendMarkup).toContain(`aria-label="${reason}"`); + expect(sendMarkup).not.toContain('aria-label="Environment disconnected"'); + expect(queueMarkup).toContain(`aria-label="${reason}"`); + expect(queueMarkup).not.toContain('aria-label="Environment disconnected"'); + }); + + it("offers Stop generation while provider admission is still starting", () => { + const markup = renderStandaloneStop({ + isRunning: false, + isStopCapable: true, + isConnecting: true, + }); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Connecting…"'); + }); + it("offers Stop generation while a running turn is waiting for user input", () => { expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index e52eb287a..0d0b28df3 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -19,6 +19,7 @@ interface ComposerPrimaryActionsProps { compact: boolean; pendingAction: PendingActionState | null; isRunning: boolean; + isStopCapable?: boolean; canQueueFollowUp: boolean; onQueueFollowUp: () => void; showPlanFollowUpPrompt: boolean; @@ -64,6 +65,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ compact, pendingAction, isRunning, + isStopCapable = isRunning, canQueueFollowUp, onQueueFollowUp, showPlanFollowUpPrompt, @@ -113,7 +115,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ if (pendingAction) { return (
- {isRunning ? renderStopGenerationButton(true) : null} + {isStopCapable ? renderStopGenerationButton(true) : null} {pendingAction.questionIndex > 0 ? ( compact ? ( + +
+ {!props.canContinueOnBoundProvider ? ( + + The bound account settings are still syncing. Starting a new thread keeps the original + selection now. + + ) : null} + + ); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index db55edcf8..0abe000e3 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -29,6 +29,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: string; lockedProvider: ProviderDriverKind | null; lockedContinuationGroupKey?: string | null; + lockedInstanceId?: ProviderInstanceId | null; /** Instance entries rendered in the sidebar + used to resolve display name. */ instanceEntries: ReadonlyArray; keybindings?: ResolvedKeybindingsConfig; @@ -200,6 +201,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model={props.model} lockedProvider={props.lockedProvider} lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} + lockedInstanceId={props.lockedInstanceId ?? null} instanceEntries={props.instanceEntries} {...(props.keybindings ? { keybindings: props.keybindings } : {})} modelOptionsByInstance={props.modelOptionsByInstance} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx index 90f28effc..246a6efb8 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -42,6 +42,8 @@ describe("ProviderStatusBanner", () => { expect(markup).toContain('role="alert"'); expect(markup).toContain('aria-label="Dismiss Codex provider warning"'); expect(markup).toContain("absolute top-2 right-2"); + expect(markup).toContain("line-clamp-3"); + expect(markup).toContain('data-slot="tooltip-trigger"'); }); it("renders on a glass surface so the timeline never reads through the banner", () => { @@ -63,4 +65,49 @@ describe("ProviderStatusBanner", () => { expect(markup).toContain('aria-label="Dismiss Codex provider error"'); }); + + it("shows an unavailable WSL2 reason even when the shadow status is disabled", () => { + const reason = + "Prime Agent is unavailable because this Pylon server is running on native Windows. Run the Pylon server and Prime Agent in WSL2, or connect this client to a Pylon server running in WSL2 or another remote environment."; + const status: ServerProvider = { + ...warningProvider(), + instanceId: ProviderInstanceId.make("primeAgent"), + driver: ProviderDriverKind.make("primeAgent"), + displayName: "Prime Agent", + enabled: false, + installed: false, + status: "disabled", + availability: "unavailable", + unavailableReason: reason, + message: reason, + auth: { status: "unknown" }, + }; + const markup = renderToStaticMarkup( + {}} />, + ); + + expect(shouldShowProviderStatusBanner(status, null)).toBe(true); + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Prime Agent is unavailable"); + expect(markup).toContain(reason); + expect(markup).toContain("whitespace-pre-wrap"); + expect(markup).not.toContain("line-clamp-3"); + expect(markup).not.toContain('data-slot="tooltip-trigger"'); + expect(markup).toContain('aria-label="Dismiss Prime Agent provider unavailable"'); + expect(markup).toContain('data-variant="error"'); + }); + + it("keeps an ordinary disabled provider hidden", () => { + const status: ServerProvider = { + ...warningProvider(), + enabled: false, + status: "disabled", + }; + + expect(getProviderStatusBannerKey(status)).toBeNull(); + expect(shouldShowProviderStatusBanner(status, null)).toBe(false); + expect( + renderToStaticMarkup( {}} />), + ).toBe(""); + }); }); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 1c7571b96..42021a832 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -4,12 +4,20 @@ import { InfoIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; +import { getProviderUnavailablePresentation } from "../../providerInstances"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { - return !status || status.status === "ready" || status.status === "disabled" - ? null - : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000"); + if (!status) return null; + const unavailable = getProviderUnavailablePresentation(status); + if (!unavailable && (status.status === "ready" || status.status === "disabled")) return null; + return [ + status.instanceId, + status.availability ?? "available", + status.status, + status.auth.status, + unavailable?.detail ?? status.message ?? "", + ].join("\u0000"); } export function shouldShowProviderStatusBanner( @@ -27,48 +35,58 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ onDismiss: () => void; status: ServerProvider | null; }) { - if (!status || status.status === "ready" || status.status === "disabled") { - return null; - } + if (!status) return null; + const unavailable = getProviderUnavailablePresentation(status); + if (!unavailable && (status.status === "ready" || status.status === "disabled")) return null; const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); - const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; - const title = isUnauthenticated - ? `${providerName} is unauthenticated` - : `${providerName} provider status`; - const message = isUnauthenticated - ? "Sign in via the CLI to authenticate again." - : (status.message ?? - (status.status === "error" - ? `${providerName} provider is unavailable.` - : `${providerName} provider has limited availability.`)); + const isUnauthenticated = + !unavailable && status.status === "error" && status.auth.status === "unauthenticated"; + const title = unavailable + ? `${providerName} is unavailable` + : isUnauthenticated + ? `${providerName} is unauthenticated` + : `${providerName} provider status`; + const message = unavailable + ? unavailable.detail + : isUnauthenticated + ? "Sign in via the CLI to authenticate again." + : (status.message ?? + (status.status === "error" + ? `${providerName} provider is unavailable.` + : `${providerName} provider has limited availability.`)); + const severity = unavailable ? "unavailable" : status.status; return (
{title}
- - {message}
} - /> - - {message} - - + {unavailable ? ( +
{message}
+ ) : ( + + {message}
} + /> + + {message} + + + )}
", + componentName: "SubmitButton", + source: null, + styles: ".submit { color: white; }", + }); + store.addPreviewAnnotation(threadRef, previewAnnotation); + store.addReviewComment(threadRef, reviewComment); + store.addImage(threadRef, image); + store.syncPersistedAttachments(threadRef, [ + { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: image.previewUrl, + }, + ]); + store.addFiles(threadRef, [file]); + store.setFileUpload(threadRef, file.id, TEST_ENVIRONMENT_ID, "pending-move-conflict-file"); + store.setProviderBindingConflict(threadRef, CLAUDE_AGENT_INSTANCE); + store.setProjectDraftThreadId(projectRef, destinationDraftId, { + threadId: destinationThreadId, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + + store.transferComposerContentSnapshotForProviderConflict(threadRef, destinationDraftId); + + const destination = store.getComposerDraft(destinationDraftId); + expect(destination).toMatchObject({ + prompt: `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} move this exact prompt`, + activeProvider: CODEX_INSTANCE, + modelSelectionExplicit: true, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + expect(destination?.images.map((entry) => entry.id)).toEqual([image.id]); + expect(destination?.files).toMatchObject([ + { + id: file.id, + uploadedAttachmentId: "pending-move-conflict-file", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); + expect(destination?.terminalContexts).toMatchObject([ + { id: "move-conflict-terminal", threadId: destinationThreadId }, + ]); + expect(destination?.elementContexts).toMatchObject([{ threadId: destinationThreadId }]); + expect(destination?.previewAnnotations).toEqual([previewAnnotation]); + expect(destination?.reviewComments).toEqual([reviewComment]); + expect(destination?.modelSelectionByProvider).toEqual({ + [CODEX_INSTANCE]: originalSelection, + }); + + const source = store.getComposerDraft(threadRef); + expect(source).toMatchObject({ + prompt: "", + images: [], + files: [], + terminalContexts: [], + elementContexts: [], + previewAnnotations: [], + reviewComments: [], + activeProvider: CLAUDE_AGENT_INSTANCE, + runtimeMode: null, + interactionMode: null, + }); + expect(source?.providerBindingConflict).toBeUndefined(); + expect(source?.modelSelectionByProvider[CODEX_INSTANCE]).toBeUndefined(); + expect(source?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]).toEqual(boundSelection); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const hydrated = options.merge( + options.partialize(useComposerDraftStore.getState()), + useComposerDraftStore.getInitialState(), + ); + const hydratedDestination = hydrated.getComposerDraft(destinationDraftId); + expect(hydratedDestination?.files).toMatchObject([ + { + id: file.id, + uploadedAttachmentId: "pending-move-conflict-file", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); + expect(hydratedDestination?.terminalContexts).toMatchObject([ + { id: "move-conflict-terminal", threadId: destinationThreadId }, + ]); + expect(hydratedDestination?.elementContexts).toMatchObject([{ threadId: destinationThreadId }]); + expect(hydratedDestination?.previewAnnotations).toEqual([previewAnnotation]); + expect(hydratedDestination?.reviewComments).toEqual([reviewComment]); + expect(hydratedDestination?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual( + originalSelection, + ); + }); + + it("does not overwrite an existing destination composer during conflict recovery", () => { + const store = useComposerDraftStore.getState(); + const destinationDraftId = DraftId.make("draft-provider-conflict-existing"); + const originalSelection = modelSelection(CODEX_DRIVER, "gpt-5.4"); + store.setPrompt(threadRef, "source content"); + store.setModelSelection(threadRef, originalSelection, { explicit: true }); + store.setProviderBindingConflict(threadRef, CLAUDE_AGENT_INSTANCE); + store.setProjectDraftThreadId( + scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make("project-provider-conflict-existing")), + destinationDraftId, + { threadId: ThreadId.make("thread-provider-conflict-existing") }, + ); + store.setPrompt(destinationDraftId, "unrelated destination content"); + + store.transferComposerContentSnapshotForProviderConflict(threadRef, destinationDraftId); + + expect(store.getComposerDraft(threadRef)?.prompt).toBe("source content"); + expect(store.getComposerDraft(threadRef)?.providerBindingConflict).toBeDefined(); + expect(store.getComposerDraft(destinationDraftId)?.prompt).toBe( + "unrelated destination content", + ); + }); + it("stores a model selection in the draft", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fe1dab199..2bb7c1e17 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -64,7 +64,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 9; +const COMPOSER_DRAFT_STORAGE_VERSION = 10; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -198,6 +198,16 @@ const PersistedElementContextDraft = Schema.Struct({ }); type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; +const PersistedComposerProviderBindingConflict = Schema.Struct({ + boundInstanceId: ProviderInstanceId, + originalSelection: ModelSelection, + runtimeMode: Schema.NullOr(RuntimeMode), + interactionMode: Schema.NullOr(ProviderInteractionMode), +}); + +type PersistedComposerProviderBindingConflict = + typeof PersistedComposerProviderBindingConflict.Type; + const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), @@ -223,6 +233,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ // selections (project default / sticky) leave it unset so later seeds can // replace them; legacy entries predate the flag and read as seeded too. modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), + providerBindingConflict: Schema.optionalKey(PersistedComposerProviderBindingConflict), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -325,6 +336,16 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ * Composer content keyed by either a draft session (`DraftId`) or a real server * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. */ +export interface ComposerProviderBindingConflict { + /** Live provider/account another client bound this thread to. */ + boundInstanceId: ProviderInstanceId; + /** Exact explicit target the unsent draft had before that binding appeared. */ + originalSelection: ModelSelection; + /** Provider-shaped mode snapshots that follow the draft only into a new thread. */ + runtimeMode: RuntimeMode | null; + interactionMode: ProviderInteractionMode | null; +} + export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; @@ -358,6 +379,8 @@ export interface ComposerThreadDraftState { * may replace it. Legacy entries predate the flag and read as seeded. */ modelSelectionExplicit?: boolean; + /** Durable user-decision barrier for a cross-client provider binding conflict. */ + providerBindingConflict?: ComposerProviderBindingConflict; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -519,6 +542,25 @@ interface ComposerDraftStoreState { setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; + /** + * Record or clear a durable cross-client provider binding conflict without + * changing the draft's selected provider, model, modes, text, or attachments. + */ + setProviderBindingConflict: ( + threadRef: ComposerThreadTarget, + boundInstanceId: ProviderInstanceId | null, + ) => void; + /** Apply the user's explicit choice to continue on the live bound provider. */ + continueOnBoundProvider: ( + threadRef: ComposerThreadTarget, + modelSelection: ModelSelection, + ) => void; + /** + * Atomically moves a complete unsent composer snapshot into a fresh draft + * after a provider-binding conflict. The snapshot includes every attachment + * and context type plus the exact original provider/model and mode choices. + */ + transferComposerContentSnapshotForProviderConflict: (from: ScopedThreadRef, to: DraftId) => void; setModelSelection: ( threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, @@ -848,6 +890,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.reviewComments.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && + draft.providerBindingConflict === undefined && draft.runtimeMode === null && draft.interactionMode === null ); @@ -1028,6 +1071,31 @@ type NormalizedModelSelection = Omit & { readonly instanceId: ProviderInstanceId; }; +function normalizeProviderBindingConflict( + value: unknown, +): ComposerProviderBindingConflict | undefined { + if (!value || typeof value !== "object") return undefined; + const candidate = value as Record; + const boundInstanceId = normalizeProviderInstanceId(candidate.boundInstanceId); + const originalSelection = normalizeModelSelection(candidate.originalSelection); + if ( + boundInstanceId === null || + originalSelection === null || + originalSelection.instanceId === boundInstanceId + ) { + return undefined; + } + return { + boundInstanceId, + originalSelection, + runtimeMode: isRuntimeMode(candidate.runtimeMode) ? candidate.runtimeMode : null, + interactionMode: + candidate.interactionMode === "plan" || candidate.interactionMode === "default" + ? candidate.interactionMode + : null, + }; +} + // ── Legacy sync helpers (used only during migration from v2 storage) ── // // These operate against the legacy kind-keyed `modelOptions` map. The @@ -1859,6 +1927,19 @@ function normalizePersistedDraftsByThreadId( let modelSelectionByProvider: Partial> = {}; let activeProvider: ProviderInstanceId | null = null; let modelSelectionExplicit: true | undefined = undefined; + const normalizedProviderBindingConflict = normalizeProviderBindingConflict( + draftCandidate.providerBindingConflict, + ); + const providerBindingConflict: + | DeepMutable + | undefined = normalizedProviderBindingConflict + ? { + ...normalizedProviderBindingConflict, + originalSelection: cloneModelSelection( + normalizedProviderBindingConflict.originalSelection, + ), + } + : undefined; if ( draftCandidate.modelSelectionByProvider && @@ -1912,6 +1993,7 @@ function normalizePersistedDraftsByThreadId( elementContexts.length === 0 && reviewComments.length === 0 && !hasModelData && + providerBindingConflict === undefined && !runtimeMode && !interactionMode ) { @@ -1943,6 +2025,7 @@ function normalizePersistedDraftsByThreadId( ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), + ...(providerBindingConflict ? { providerBindingConflict } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), }; @@ -1972,6 +2055,7 @@ function stripLegacyModelSeedsFromEmptyDraftSessions( if ( draftThreadsByThreadKey[threadKey] === undefined || draft.modelSelectionExplicit === true || + draft.providerBindingConflict !== undefined || persistedComposerDraftHasUserContent(draft) ) { return [[threadKey, draft]]; @@ -2045,6 +2129,7 @@ function partializeComposerDraftStoreState( draft.previewAnnotations.length === 0 && draft.reviewComments.length === 0 && !hasModelData && + draft.providerBindingConflict === undefined && draft.runtimeMode === null && draft.interactionMode === null ) { @@ -2123,6 +2208,18 @@ function partializeComposerDraftStoreState( ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), + ...(draft.providerBindingConflict + ? { + providerBindingConflict: { + boundInstanceId: draft.providerBindingConflict.boundInstanceId, + originalSelection: cloneModelSelection( + draft.providerBindingConflict.originalSelection, + ), + runtimeMode: draft.providerBindingConflict.runtimeMode, + interactionMode: draft.providerBindingConflict.interactionMode, + }, + } + : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; @@ -2385,6 +2482,16 @@ function toHydratedThreadDraft( modelSelectionByProvider, activeProvider, ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), + ...(persistedDraft.providerBindingConflict + ? { + providerBindingConflict: { + ...persistedDraft.providerBindingConflict, + originalSelection: cloneModelSelection( + persistedDraft.providerBindingConflict.originalSelection, + ), + }, + } + : {}), runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -2908,6 +3015,172 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + setProviderBindingConflict: (threadRef, boundInstanceId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing) return state; + if (boundInstanceId === null) { + if (existing.providerBindingConflict === undefined) return state; + const { providerBindingConflict: _conflict, ...nextDraft } = existing; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: nextDraft, + }, + }; + } + const originalSelection = existing.activeProvider + ? existing.modelSelectionByProvider[existing.activeProvider] + : undefined; + if ( + originalSelection === undefined || + originalSelection.instanceId === boundInstanceId + ) { + return state; + } + const providerBindingConflict: ComposerProviderBindingConflict = { + boundInstanceId, + originalSelection: cloneModelSelection(originalSelection), + runtimeMode: existing.runtimeMode, + interactionMode: existing.interactionMode, + }; + if (Equal.equals(existing.providerBindingConflict, providerBindingConflict)) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + providerBindingConflict, + }, + }, + }; + }); + }, + continueOnBoundProvider: (threadRef, modelSelection) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) return; + const normalized = normalizeModelSelection(modelSelection); + if (!normalized) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if ( + !existing?.providerBindingConflict || + existing.providerBindingConflict.boundInstanceId !== normalized.instanceId + ) { + return state; + } + const { providerBindingConflict: _conflict, ...retained } = existing; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...retained, + modelSelectionByProvider: { + ...existing.modelSelectionByProvider, + [normalized.instanceId]: normalized, + }, + activeProvider: normalized.instanceId, + modelSelectionExplicit: true, + // Provider-shaped modes from the conflicting selection do + // not cross onto the bound runtime. The thread snapshot is + // authoritative until the user changes them afterwards. + runtimeMode: null, + interactionMode: null, + }, + }, + }; + }); + }, + transferComposerContentSnapshotForProviderConflict: (from, to) => { + const fromKey = resolveComposerDraftKey(get(), from) ?? ""; + const toKey = resolveComposerDraftKey(get(), to) ?? ""; + if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) return; + set((state) => { + const source = state.draftsByThreadKey[fromKey]; + const destinationSession = state.draftThreadsByThreadKey[toKey]; + const conflict = source?.providerBindingConflict; + // This is intentionally not a general composer move. Conflict + // recovery always creates a fresh draft in the same environment. + // Refuse to overwrite another composer's state or to carry + // environment-bound uploads somewhere they cannot be reached. + if ( + !source || + !conflict || + !destinationSession || + destinationSession.environmentId !== from.environmentId || + state.draftsByThreadKey[toKey] !== undefined + ) { + return state; + } + + const originalSelection = cloneModelSelection(conflict.originalSelection); + const destinationThreadId = destinationSession.threadId; + const nextDestination: ComposerThreadDraftState = { + prompt: source.prompt, + images: source.images, + files: source.files, + nonPersistedImageIds: source.nonPersistedImageIds, + persistedAttachments: source.persistedAttachments, + terminalContexts: source.terminalContexts.map((context) => ({ + ...context, + threadId: destinationThreadId, + })), + elementContexts: source.elementContexts.map((context) => ({ + ...context, + threadId: destinationThreadId, + })), + previewAnnotations: source.previewAnnotations, + reviewComments: source.reviewComments, + modelSelectionByProvider: { + [originalSelection.instanceId]: originalSelection, + }, + activeProvider: originalSelection.instanceId, + modelSelectionExplicit: true, + runtimeMode: conflict.runtimeMode ?? destinationSession.runtimeMode, + interactionMode: conflict.interactionMode ?? destinationSession.interactionMode, + }; + + const modelSelectionByProvider = { ...source.modelSelectionByProvider }; + delete modelSelectionByProvider[originalSelection.instanceId]; + const { + providerBindingConflict: _conflict, + modelSelectionExplicit: _explicit, + ...retainedSource + } = source; + // Do not revoke image preview URLs: ownership moves to the new + // composer in this same transaction. + const nextSource: ComposerThreadDraftState = { + ...retainedSource, + prompt: "", + images: [], + files: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + elementContexts: [], + previewAnnotations: [], + reviewComments: [], + modelSelectionByProvider, + activeProvider: conflict.boundInstanceId, + runtimeMode: null, + interactionMode: null, + }; + const nextDraftsByThreadKey = { + ...state.draftsByThreadKey, + [toKey]: nextDestination, + }; + if (shouldRemoveDraft(nextSource)) { + delete nextDraftsByThreadKey[fromKey]; + } else { + nextDraftsByThreadKey[fromKey] = nextSource; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, setModelSelection: (threadRef, modelSelection, opts) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -3897,6 +4170,11 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +/** Force the latest conflict/move transaction into localStorage before navigation. */ +export function flushComposerDraftStore(): void { + composerDebouncedStorage.flush(); +} + export function beginBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void { const threadKey = scopedThreadKey(threadRef); useComposerDraftStore.setState((state) => { diff --git a/apps/web/src/composerInstanceSelection.test.ts b/apps/web/src/composerInstanceSelection.test.ts index 9040f6d06..4a5b0e027 100644 --- a/apps/web/src/composerInstanceSelection.test.ts +++ b/apps/web/src/composerInstanceSelection.test.ts @@ -1,7 +1,10 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveComposerInstanceSelection } from "./composerInstanceSelection"; +import { + canStartComposerTurn, + resolveComposerInstanceSelection, +} from "./composerInstanceSelection"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances"; const NOW_MS = Date.parse("2026-08-06T12:00:00.000Z"); @@ -11,6 +14,8 @@ function provider(input: { readonly driver: string; readonly enabled?: boolean; readonly status?: ServerProvider["status"]; + readonly availability?: ServerProvider["availability"]; + readonly unavailableReason?: string; readonly continuationGroupKey?: string; readonly drainedUntil?: string; }): ServerProvider { @@ -21,6 +26,8 @@ function provider(input: { installed: true, version: null, status: input.status ?? "ready", + ...(input.availability ? { availability: input.availability } : {}), + ...(input.unavailableReason ? { unavailableReason: input.unavailableReason } : {}), auth: { status: "authenticated" }, checkedAt: "2026-08-06T11:59:00.000Z", models: [], @@ -47,6 +54,16 @@ const kind = (value: string) => ProviderDriverKind.make(value); const CLAUDE_WORK = provider({ instanceId: "claudeAgent", driver: "claudeAgent" }); const CLAUDE_PERSONAL = provider({ instanceId: "claude_personal", driver: "claudeAgent" }); const CODEX = provider({ instanceId: "codex", driver: "codex" }); +const PRIME_UNAVAILABLE_REASON = + "Prime Agent cannot materialize on this Pylon server. Choose another provider or connect to a supported environment."; +const PRIME_UNAVAILABLE = provider({ + instanceId: "primeAgent", + driver: "primeAgent", + enabled: false, + status: "disabled", + availability: "unavailable", + unavailableReason: PRIME_UNAVAILABLE_REASON, +}); const entriesOf = (...providers: ServerProvider[]) => deriveProviderInstanceEntries(providers); @@ -60,8 +77,7 @@ const base = { } as const; describe("resolveComposerInstanceSelection", () => { - // The picker's unsaved pick must win, or the UI appears to ignore it. - it("prefers the draft's picker choice over the thread's binding", () => { + it("keeps the live session authoritative over a stale device draft", () => { const selection = resolveComposerInstanceSelection({ ...base, entries: entriesOf(CLAUDE_WORK, CLAUDE_PERSONAL, CODEX), @@ -69,8 +85,34 @@ describe("resolveComposerInstanceSelection", () => { sessionInstanceId: id("claudeAgent"), }); - expect(selection.instanceId).toBe("codex"); - expect(selection.driverKind).toBe("codex"); + expect(selection.instanceId).toBe("claudeAgent"); + expect(selection.driverKind).toBe("claudeAgent"); + expect(selection.draftConflictsWithSessionBinding).toBe(true); + }); + + it("keeps an explicit same-driver exact-continuation account choice", () => { + const work = provider({ + instanceId: "codex", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + const personal = provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:shared", + }); + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(work, personal), + draftActiveProvider: id("codex_personal"), + sessionInstanceId: id("codex"), + lockedProvider: kind("codex"), + }); + + expect(selection.instanceId).toBe("codex_personal"); + expect(selection.entry?.instanceId).toBe("codex_personal"); + expect(selection.draftConflictsWithSessionBinding).toBe(false); + expect(canStartComposerTurn(selection)).toBe(true); }); it("falls through thread, then project, when nothing was picked", () => { @@ -90,6 +132,83 @@ describe("resolveComposerInstanceSelection", () => { ).toBe("codex"); }); + it.each([ + ["stored default", { draftActiveProvider: id("primeAgent") }], + ["project default", { projectInstanceId: id("primeAgent") }], + ["persisted thread", { threadInstanceId: id("primeAgent") }], + ["live thread session", { sessionInstanceId: id("primeAgent") }], + ] as const)( + "holds an unavailable Prime %s instead of routing to ready Codex", + (_label, source) => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(PRIME_UNAVAILABLE, CODEX), + ...source, + }); + + expect(selection).toMatchObject({ + instanceId: "primeAgent", + driverKind: "primeAgent", + requestedDriverKind: "primeAgent", + blockedByUnavailablePreference: true, + }); + expect(selection.entry?.snapshot.unavailableReason).toBe(PRIME_UNAVAILABLE_REASON); + expect(selection.entry?.snapshot.availability).toBe("unavailable"); + expect(canStartComposerTurn(selection)).toBe(false); + }, + ); + + it("unlocks only after an explicit provider selection", () => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(PRIME_UNAVAILABLE, CODEX), + draftActiveProvider: id("codex"), + threadInstanceId: id("primeAgent"), + projectInstanceId: id("primeAgent"), + }); + + expect(selection).toMatchObject({ + instanceId: "codex", + driverKind: "codex", + blockedByUnavailablePreference: false, + }); + expect(selection.entry?.snapshot).toBe(CODEX); + expect(canStartComposerTurn(selection)).toBe(true); + }); + + it.each([ + ["warning", true], + ["error", false], + ] as const)( + "treats a %s provider snapshot as the matching admission state", + (status, admitted) => { + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(provider({ instanceId: "codex", driver: "codex", status })), + projectInstanceId: id("codex"), + }); + + expect(canStartComposerTurn(selection)).toBe(admitted); + }, + ); + + it("unlocks the stored Prime selection when its provider becomes available", () => { + const primeAvailable = provider({ instanceId: "primeAgent", driver: "primeAgent" }); + const selection = resolveComposerInstanceSelection({ + ...base, + entries: entriesOf(primeAvailable, CODEX), + projectInstanceId: id("primeAgent"), + }); + + expect(selection).toMatchObject({ + instanceId: "primeAgent", + driverKind: "primeAgent", + blockedByUnavailablePreference: false, + }); + expect(selection.entry?.snapshot).toBe(primeAvailable); + expect(canStartComposerTurn(selection)).toBe(true); + }); + // A drained account is only routed around while nothing is pinned to it. it("routes an unpinned selection around a drained account", () => { const drained = provider({ @@ -131,6 +250,7 @@ describe("resolveComposerInstanceSelection", () => { expect(selection.instanceId).toBe("claude_personal"); expect(selection.requestedDriverKind).toBe("claudeAgent"); + expect(selection.blockedByUnavailablePreference).toBe(false); }); // Once locked, a persisted id from another driver or continuation group @@ -147,7 +267,7 @@ describe("resolveComposerInstanceSelection", () => { expect(selection.instanceId).toBe("claudeAgent"); }); - it("stays inside the locked continuation group", () => { + it("never falls across instances inside a locked continuation group", () => { const work = provider({ instanceId: "claudeAgent", driver: "claudeAgent", @@ -172,7 +292,8 @@ describe("resolveComposerInstanceSelection", () => { }); expect(selection.lockedContinuationGroupKey).toBe("org-a"); - expect(selection.instanceId).toBe("claude_work_2"); + expect(selection.instanceId).toBe("claudeAgent"); + expect(canStartComposerTurn(selection)).toBe(false); }); it("reports no provider when nothing is selectable", () => { @@ -181,5 +302,6 @@ describe("resolveComposerInstanceSelection", () => { expect(selection.instanceId).toBe(NO_PROVIDER_MODEL_SELECTION.instanceId); expect(selection.entry).toBeUndefined(); expect(selection.driverKind).toBe("unconfigured"); + expect(canStartComposerTurn(selection)).toBe(false); }); }); diff --git a/apps/web/src/composerInstanceSelection.ts b/apps/web/src/composerInstanceSelection.ts index c86807e85..2d6c2309f 100644 --- a/apps/web/src/composerInstanceSelection.ts +++ b/apps/web/src/composerInstanceSelection.ts @@ -7,23 +7,29 @@ * showing one account while the composer sends to another. * * Priority: - * 1. The composer draft's `activeProvider` — the user's unsaved pick from - * the model picker (must win, otherwise the UI appears to ignore picker - * selections). - * 2. The thread's live session binding (server-side saved selection). + * 1. The thread's live session binding. Once present it is the only routing + * target for this thread. + * 2. The composer draft's `activeProvider` while the thread is unbound. * 3. The thread's persisted model selection. * 4. The project default's instance id. * 5. First enabled entry matching the current driver kind. * 6. First enabled entry overall / default instance for the kind. * - * Candidates 1 and 2 are pinned: an explicit picker choice and a thread's - * live session binding are honored even when that account is spent, so an - * existing thread never migrates off the account it started on. Everything - * below them has not bound to a session yet and is free to route around a - * drained account, in configured priority order. + * A draft from another device can outlive the session transition. It is + * reported as conflicting and ignored; callers clear only that provider-shaped + * draft state while keeping prompt text and attachments. + * + * Any preferred entry that is explicitly unavailable is held as a blocked + * selection regardless of priority. Availability means the configured driver + * cannot materialize on this server, so silently routing the turn to another + * provider or account would violate the stored choice. Ordinary disabled, + * missing, stale, and temporarily unhealthy preferences keep their existing + * fallback behavior. * * @module composerInstanceSelection */ +import { getProviderAdmissionAvailability } from "@t3tools/client-runtime/providerAvailability"; +import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; import { @@ -66,6 +72,14 @@ export interface ComposerInstanceSelection { * no instance of that kind is available. */ readonly requestedDriverKind: ProviderDriverKind; + /** + * The preferred entry exists but cannot materialize on this server. The + * composer must keep showing this entry and reject turns until a user picks + * another provider or the entry becomes available. + */ + readonly blockedByUnavailablePreference: boolean; + /** A device-local draft points at another instance than the live session. */ + readonly draftConflictsWithSessionBinding: boolean; /** * Continuation group a locked thread must stay inside, or null while the * thread is unlocked or its instance has no group. @@ -77,9 +91,25 @@ export function resolveComposerInstanceSelection( input: ComposerInstanceSelectionInput, ): ComposerInstanceSelection { const { entries, lockedProvider, nowMs } = input; + const sessionInstanceId = input.sessionInstanceId ?? null; + const providers = entries.map((entry) => entry.snapshot); + const draftTransition = + sessionInstanceId !== null && + input.draftActiveProvider != null && + input.draftActiveProvider !== sessionInstanceId + ? resolveProviderContinuationTransition({ + providers, + currentInstanceId: sessionInstanceId, + targetInstanceId: input.draftActiveProvider, + }) + : null; + const draftConflictsWithSessionBinding = draftTransition?.compatible === false; + const compatibleDraftInstanceId = + draftTransition?.compatible === true ? (input.draftActiveProvider ?? null) : null; const threadProvider = - input.sessionInstanceId ?? input.threadInstanceId ?? input.projectInstanceId ?? null; - const explicitSelectedInstanceId = input.draftActiveProvider ?? threadProvider; + sessionInstanceId ?? input.threadInstanceId ?? input.projectInstanceId ?? null; + const explicitSelectedInstanceId = + compatibleDraftInstanceId ?? sessionInstanceId ?? input.draftActiveProvider ?? threadProvider; const unlockedSelectedProvider = resolveProviderDriverKindForInstanceSelection(entries, [], explicitSelectedInstanceId) ?? @@ -97,36 +127,63 @@ export function resolveComposerInstanceSelection( const candidates: ReadonlyArray<{ readonly instanceId: ProviderInstanceId | null | undefined; readonly pinned: boolean; - }> = [ - { instanceId: input.draftActiveProvider, pinned: true }, - { instanceId: input.sessionInstanceId, pinned: true }, - { instanceId: input.threadInstanceId, pinned: false }, - { instanceId: input.projectInstanceId, pinned: false }, - ]; + }> = sessionInstanceId + ? [ + ...(compatibleDraftInstanceId === null + ? [] + : [{ instanceId: compatibleDraftInstanceId, pinned: true }]), + { instanceId: sessionInstanceId, pinned: true }, + ] + : [ + { instanceId: input.draftActiveProvider, pinned: true }, + { instanceId: input.threadInstanceId, pinned: false }, + { instanceId: input.projectInstanceId, pinned: false }, + ]; const finish = ( instanceId: ProviderInstanceId, entry: ProviderInstanceEntry | undefined, + blockedByUnavailablePreference = false, ): ComposerInstanceSelection => ({ instanceId, driverKind: entry?.driverKind ?? requestedDriverKind, entry, requestedDriverKind, + blockedByUnavailablePreference, + draftConflictsWithSessionBinding, lockedContinuationGroupKey, }); for (const candidate of candidates) { if (!candidate.instanceId) continue; - const match = entries.find( - (entry) => entry.instanceId === candidate.instanceId && entry.enabled && entry.isAvailable, - ); - if (!match) continue; - // When locked to a specific driver kind, ignore persisted instance ids - // from a different kind or continuation group. + const match = entries.find((entry) => entry.instanceId === candidate.instanceId); + if (!match) { + if (sessionInstanceId && candidate.instanceId === sessionInstanceId) { + return finish(sessionInstanceId, undefined); + } + continue; + } + if (sessionInstanceId && candidate.instanceId === sessionInstanceId) { + return finish(match.instanceId, match, !match.isAvailable); + } + // A started thread can select another instance only when both snapshots + // prove the exact same non-empty continuation identity. if (lockedProvider && match.driverKind !== lockedProvider) continue; - if (lockedContinuationGroupKey && match.continuationGroupKey !== lockedContinuationGroupKey) { + if ( + lockedInstanceId !== null && + !resolveProviderContinuationTransition({ + providers, + currentInstanceId: lockedInstanceId, + targetInstanceId: match.instanceId, + }).compatible + ) { continue; } + // Explicit unavailability is a durable materialization barrier, not a + // transient readiness signal. Preserve the exact requested routing key so + // the UI can show the server's remediation and require a deliberate pick. + if (!match.isAvailable) return finish(match.instanceId, match, true); + if (!match.enabled) continue; // Drained and unpinned: defer to the ordered fallback below, which // prefers a healthy instance and lands back here only when every instance // is drained. @@ -137,7 +194,12 @@ export function resolveComposerInstanceSelection( const compatibleEntries = entries.filter( (entry) => (!lockedProvider || entry.driverKind === lockedProvider) && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), + (lockedInstanceId === null || + resolveProviderContinuationTransition({ + providers, + currentInstanceId: lockedInstanceId, + targetInstanceId: entry.instanceId, + }).compatible), ); const requestedDriverEntries = compatibleEntries.filter( (entry) => entry.driverKind === requestedDriverKind, @@ -147,3 +209,18 @@ export function resolveComposerInstanceSelection( resolveSelectableProviderInstanceEntry(compatibleEntries, undefined, nowMs); return finish(fallback?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId, fallback); } + +/** Whether the resolved routing target may be admitted as a provider turn. */ +export function canStartComposerTurn(selection: ComposerInstanceSelection): boolean { + return ( + selection.entry !== undefined && + selection.entry.enabled && + selection.entry.isAvailable && + getProviderAdmissionAvailability({ + provider: selection.entry.snapshot, + instanceId: String(selection.entry.instanceId), + providerSnapshotKnown: true, + }).status === "available" && + !selection.blockedByUnavailablePreference + ); +} diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 858ccf6ef..f229c4e2c 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -5,6 +5,7 @@ import { deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, + getProviderUnavailablePresentation, isProviderInstanceDrained, isProviderInstancePickerReady, isProviderInstancePickerVisible, @@ -53,6 +54,38 @@ const model = (slug: string, isCustom = false, isDefault = false) => ({ capabilities: {}, }); +describe("getProviderUnavailablePresentation", () => { + it("lets an unavailable reason outrank disabled status and generic probe copy", () => { + const reason = "Run the Pylon server and this provider in WSL2."; + const snapshot = provider({ + provider: ProviderDriverKind.make("grok"), + instanceId: "portable_provider", + enabled: false, + availability: "unavailable", + status: "disabled", + }); + + expect( + getProviderUnavailablePresentation({ + ...snapshot, + message: "Disabled", + unavailableReason: reason, + }), + ).toEqual({ headline: "Unavailable", detail: reason }); + }); + + it("does not replace ordinary disabled presentation", () => { + const snapshot = provider({ + provider: ProviderDriverKind.make("grok"), + instanceId: "disabled_provider", + enabled: false, + status: "disabled", + }); + + expect(getProviderUnavailablePresentation(snapshot)).toBeNull(); + }); +}); + describe("isProviderInstancePickerReady", () => { it("rejects a disabled instance even while its last probe status is ready", () => { const [entry] = deriveProviderInstanceEntries([ @@ -634,7 +667,7 @@ describe("resolveDefaultProviderModelSelection", () => { expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); }); - it("replaces a stale stored instance with the first ready instance and its model", () => { + it("uses a warning instance as the first selectable fallback", () => { const providers = [ provider({ provider: ProviderDriverKind.make("codex"), @@ -654,34 +687,55 @@ describe("resolveDefaultProviderModelSelection", () => { instanceId: ProviderInstanceId.make("removed-provider"), model: "stale-model", }), + ).toEqual({ instanceId: "codex", model: "gpt-5.6" }); + }); + + it("replaces an ordinary disabled stored instance deterministically", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + models: [model("gpt-5.6")], + enabled: false, + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }), ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); }); - it.each([{ enabled: false }, { availability: "unavailable" as const }])( - "replaces an unavailable stored instance deterministically", - (requestedState) => { - const providers = [ - provider({ - provider: ProviderDriverKind.make("codex"), - instanceId: "codex", - models: [model("gpt-5.6")], - ...requestedState, - }), - provider({ - provider: ProviderDriverKind.make("claudeAgent"), - instanceId: "claudeAgent", - models: [model("claude-opus-4-8", false, true)], - }), - ]; + it("preserves an explicitly unavailable stored instance until the user replaces it", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("primeAgent"), + instanceId: "primeAgent", + enabled: false, + status: "disabled", + availability: "unavailable", + }), + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + models: [model("gpt-5.6", false, true)], + }), + ]; + const stored = { + instanceId: ProviderInstanceId.make("primeAgent"), + model: "anthropic/claude-opus-4-6", + options: [{ id: "thinkingLevel", value: "high" }], + }; - expect( - resolveDefaultProviderModelSelection(providers, { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.6", - }), - ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); - }, - ); + expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); + }); it("returns no selection for empty, disabled, unavailable, or error-only profiles", () => { expect(resolveDefaultProviderModelSelection([], null)).toBeNull(); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 4dc311325..73648d47a 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -12,6 +12,10 @@ * * @module providerInstances */ +import { + getProviderAdmissionAvailability, + getProviderUnavailablePresentation, +} from "@t3tools/client-runtime/providerAvailability"; import { DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, @@ -29,6 +33,8 @@ import { import { formatProviderDriverKindLabel } from "./providerModels"; +export { getProviderUnavailablePresentation }; + /** * Local-only placeholder used while a draft has no provider it can safely * target. It must never be persisted or dispatched; the composer disables @@ -78,7 +84,13 @@ export interface ProviderInstanceEntry { * `ready` probe status can remain in the streamed snapshot until reconciliation. */ export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean { - return entry.enabled && entry.isAvailable && entry.status === "ready"; + return ( + getProviderAdmissionAvailability({ + provider: entry.snapshot, + instanceId: String(entry.instanceId), + providerSnapshotKnown: true, + }).status === "available" + ); } /** Picker rails contain configured, enabled instances only. */ @@ -438,15 +450,22 @@ export function resolveSelectableProviderInstance( /** * Resolve the model selection persisted for a project or new thread. A valid - * stored selection is preserved byte-for-byte. Falling back to another - * instance also resets the model to that instance's own default, avoiding - * cross-provider instance/model pairs. + * stored selection is preserved byte-for-byte. A stored selection whose entry + * is explicitly unavailable is also preserved so the composer can show the + * server's remediation and require an explicit replacement. Ordinary missing, + * disabled, stale, and errored selections keep the existing fallback behavior. + * Falling back to another instance resets the model to that instance's own + * default, avoiding cross-provider instance/model pairs. */ export function resolveDefaultProviderModelSelection( providers: ReadonlyArray, selection: ModelSelection | null | undefined, nowMs: number = Date.now(), ): ModelSelection | null { + const storedProvider = providers.find( + (provider) => provider.instanceId === selection?.instanceId, + ); + if (selection && getProviderUnavailablePresentation(storedProvider)) return selection; const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId, nowMs); if (instanceId === undefined) return null; if (selection?.instanceId === instanceId) return selection; diff --git a/docs/internals/prime-agent-daemon-parity.md b/docs/internals/prime-agent-daemon-parity.md index 6206531d7..1ebffe0dd 100644 --- a/docs/internals/prime-agent-daemon-parity.md +++ b/docs/internals/prime-agent-daemon-parity.md @@ -31,7 +31,13 @@ remain distinct without exposing or persisting Prime's identifier. Missing token behavior. Invalid terminal correlation fails closed. Older ACP releases that publish no completion metadata retain prompt-response settlement. -Daemon mode is currently disabled on Windows. Prime Agent 0.8.1's named-pipe transport exposes neither a verifiable per-user ACL nor an authenticated peer handshake, so Pylon selects ACP compatibility mode rather than trust a forgeable stable pipe name. +Prime Agent provider execution is supported on macOS, Linux, and WSL2, which reports itself as Linux. +A native `win32` Pylon server fails closed at `PrimeAgentDriver.create`, before daemon or ACP selection, +status and catalog probes, capacity reads, background writing, or install/update resolution. It exposes +only the typed unavailable provider snapshot with WSL2 guidance and never uses native ACP as a fallback. +Web, desktop, and mobile clients remain supported when they connect to a WSL2 or remote environment. +Revisit native execution only after upstream Prime Agent supports Windows, then revalidate its public +process, transport, SDK, and ACP contracts before changing this ledger or removing the driver gate. Prime supervisor ownership is recorded outside the agent home. Since Prime Agent 0.8.0, the daemon has retained the registry at `~/.prime/supervisor-owners` so macOS cleanup cannot delete a long-running supervisor's authority record, and the location is deliberately global per user rather than per agent directory. Pylon therefore writes ownership records there even when a provider instance sets its own agent home, and daemon start, stop, and renewal briefly take that registry's advisory lock alongside any other Prime daemon on the machine. This is safe for Pylon because ownership conflicts are keyed on socket path and worker descriptor directory: Pylon derives a unique socket per `(state directory, provider instance)` and the descriptor directory hashes that socket, so a Pylon daemon never claims ownership over a user's interactive `prime-agent` daemon even when both share the default `~/.prime/agent` home. Prime exposes an internal environment override for the registry location; Pylon does not use it, because it strips every `PRIME_AGENT_INTERNAL_*` variable from the daemon environment by design and will not build product behavior on an unsupported knob. @@ -189,8 +195,9 @@ of Pylon's managed-path check. Approval-required sessions retain the stronger fa explicit extension, discovery and automatic reconnect disabled, zero RLM child depth, and no non-warning extension diagnostic from any path. -This bridge is daemon-only. ACP fallback is selected on Windows, when daemon mode is disabled or -unavailable, and when custom Prime launch arguments are configured. Prime Agent 0.8.1 ACP does not load +This bridge is daemon-only. On supported hosts, ACP fallback is selected when daemon mode is disabled or +unavailable and when custom Prime launch arguments are configured. Native Windows stops at the driver +boundary instead. Prime Agent 0.8.1 ACP does not load the managed bridge and drops custom tool-result `details`, so those sessions retain only Prime's standard ACP `PlanUpdated` handling. Matching managed-tool parity in ACP requires a separately reviewed bounded content envelope and credible extension verifier, or another scoped transport; it is not implied by this diff --git a/docs/internals/prime-agent-native-parity.md b/docs/internals/prime-agent-native-parity.md index 319bde91d..075d67143 100644 --- a/docs/internals/prime-agent-native-parity.md +++ b/docs/internals/prime-agent-native-parity.md @@ -16,11 +16,12 @@ Use the independently installed Prime Agent package and its public detached-daem - import `DaemonClient` and `DaemonAgentConnection` from that exact installation at runtime; - never add or bundle the 260+ MiB Prime package as a Pylon dependency; - launch one private, scoped daemon per configured Prime provider instance; -- use a short, stable Pylon-owned socket/pipe name so the user's normal Prime daemon is untouched; contain POSIX sockets in an owner-only directory below a trusted private or sticky temporary root; +- use a short, stable Pylon-owned socket name so the user's normal Prime daemon is untouched; contain it in an owner-only directory below a trusted private or sticky temporary root; - strip every inherited `PRIME_AGENT_INTERNAL_*` variable before launch, because Pylon may itself be running inside a Prime worker; - create one client-owned Prime daemon session per live Pylon thread; - persist exact Prime session identity in a server-private thread sidecar while keeping the client-visible provider resume cursor opaque, then rehydrate after server restart; -- keep ACP as an explicit compatibility fallback for installations without the supported daemon API and for Windows until Prime's named pipe has verifiable per-user access control or peer authentication. +- keep ACP as an explicit compatibility fallback on supported hosts when daemon setup cannot be used; +- support provider execution on macOS, Linux, and WSL2 only; reject native `win32` at the driver boundary before any Prime process or probe and direct users to WSL2. Prime Agent 0.8.1 exposes `prime-agent.daemon` protocol 7, schema revision 22. Pylon accepts protocol 7 or newer through the installed high-level client and negotiates server capabilities rather than pinning an internal wire schema. The shipped capability decisions now live in the [daemon parity ledger](prime-agent-daemon-parity.md); this document remains the original delivery plan. @@ -95,7 +96,7 @@ Each slice includes contracts, adapter operation/event mapping, command/event/pr 7. **Goal and automation:** goals/gates, heartbeats, schedules, notifications. 8. **Side questions and history:** independent side cards, history tree, fork to a new Pylon thread/worktree. 9. **Generic provider auth and permission gate:** supported auth callbacks, credential status, host-gated approvals. -10. **Hardening:** packaged desktop, Windows named pipe/process behavior, remote/relay/tunnel, concurrency and resource use, upgrade/reconnect compatibility. +10. **Hardening:** packaged desktop, WSL2 and remote Windows-client behavior, concurrency and resource use, upgrade/reconnect compatibility. Revisit native Windows only after upstream Prime Agent supports it and its public process, transport, SDK, and ACP contracts can be validated. ## Verification rules diff --git a/docs/internals/providers.md b/docs/internals/providers.md index b2e1fe01c..8f2ff10cd 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -24,10 +24,15 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. -Prime Agent uses its public detached-daemon APIs as the primary runtime on POSIX hosts. Windows fails -closed to ACP compatibility mode because Prime Agent 0.8.1 does not expose a verifiable named-pipe -ACL or authenticated peer handshake; a stable pipe name alone is not a trust boundary. One scoped daemon belongs -to a provider instance, while each Pylon thread owns an isolated, deterministic native session +Prime Agent uses its public detached-daemon APIs as the primary runtime on macOS, Linux, and WSL2 +(which reports itself as Linux). `PrimeAgentDriver.create` rejects a native `win32` server before +maintenance resolution, backend negotiation, status or catalog probes, capacity reads, adapter +construction, or background-writing construction. The instance becomes the existing typed unavailable +shadow with WSL2 guidance; it does not fall back to ACP. Web, desktop, and mobile clients remain supported +when they connect to a WSL2 or remote environment, and Windows desktop packaging is independent of +provider-runtime support. Revisit native execution only after upstream Prime Agent supports Windows; +that review must then validate its public process, transport, SDK, and ACP contracts before removing the +gate. One scoped daemon belongs to a provider instance, while each Pylon thread owns an isolated, deterministic native session directory. The client-visible continuation cursor stays an opaque marker; a server-private sidecar binds it to the exact stable Prime transcript identity and verifies the saved file before cold resume. On POSIX filesystems the thread session directory is owner-only, and its identity, diff --git a/docs/user/providers-prime-agent.md b/docs/user/providers-prime-agent.md index 922a19246..a966ec355 100644 --- a/docs/user/providers-prime-agent.md +++ b/docs/user/providers-prime-agent.md @@ -1,7 +1,10 @@ # Prime Agent Pylon can run [Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent) as a provider on the -device that owns your environment. Prime Agent is not bundled with Pylon. +device that owns your environment. Prime Agent is not bundled with Pylon. The environment host must +run macOS, Linux, or WSL2. A Pylon server running directly on Windows shows Prime Agent as unavailable +and does not start Prime Agent. Run the server and Prime Agent inside WSL2 instead. Any Pylon web, +desktop, or mobile client can also connect to a WSL2 or remote environment that runs Prime Agent. ## Install And Sign In @@ -97,7 +100,8 @@ session rather than guessing whether it was your answer or unrelated background Restarting the Pylon server is a separate boundary and does not yet adopt Prime work that is still running in another process. -On Windows, Pylon currently uses ACP compatibility mode because Prime Agent 0.8.1's public named-pipe daemon transport does not expose a verifiable per-user ACL or authenticated handshake. Native daemon mode remains fail-closed there until the transport can prevent another local OS user from impersonating or connecting to the daemon. +Native Windows is not a Prime Agent provider runtime. Pylon does not fall back to ACP there. Use +WSL2, where the server runs as Linux, or connect this client to another supported environment. Pylon uses a short-lived, prompt-free Prime Agent RPC process to bootstrap the configured-model catalog until a compatible daemon session publishes a usable list from Prime Agent's public model diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index cff49256e..db117aa48 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -59,6 +59,14 @@ "types": "./src/voice-input/index.ts", "default": "./src/voice-input/index.ts" }, + "./providerAvailability": { + "types": "./src/providerAvailability.ts", + "default": "./src/providerAvailability.ts" + }, + "./providerContinuation": { + "types": "./src/providerContinuation.ts", + "default": "./src/providerContinuation.ts" + }, "./relay": { "types": "./src/relay/index.ts", "default": "./src/relay/index.ts" diff --git a/packages/client-runtime/src/providerAvailability.test.ts b/packages/client-runtime/src/providerAvailability.test.ts new file mode 100644 index 000000000..90fd949c3 --- /dev/null +++ b/packages/client-runtime/src/providerAvailability.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderUnavailablePresentation } from "./providerAvailability.ts"; + +describe("getProviderUnavailablePresentation", () => { + it("prefers the actionable unavailable reason over a probe message", () => { + expect( + getProviderUnavailablePresentation({ + availability: "unavailable", + unavailableReason: "Run the Pylon server and this provider in WSL2.", + message: "Disabled", + }), + ).toEqual({ + headline: "Unavailable", + detail: "Run the Pylon server and this provider in WSL2.", + }); + }); + + it("falls back from an empty reason to the probe message", () => { + expect( + getProviderUnavailablePresentation({ + availability: "unavailable", + unavailableReason: " ", + message: "This provider cannot run here.", + }), + ).toEqual({ headline: "Unavailable", detail: "This provider cannot run here." }); + }); + + it("uses provider-neutral copy when the server has no detail", () => { + expect(getProviderUnavailablePresentation({ availability: "unavailable" })).toEqual({ + headline: "Unavailable", + detail: "This provider is unavailable in the current environment.", + }); + }); + + it("does not replace ordinary disabled presentation", () => { + expect(getProviderUnavailablePresentation({ message: "Disabled" })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/providerAvailability.ts b/packages/client-runtime/src/providerAvailability.ts new file mode 100644 index 000000000..3cb717802 --- /dev/null +++ b/packages/client-runtime/src/providerAvailability.ts @@ -0,0 +1,98 @@ +import type { ServerProvider } from "@t3tools/contracts"; + +export interface ProviderUnavailablePresentation { + readonly headline: "Unavailable"; + readonly detail: string; +} + +export type ProviderAdmissionAvailability = + | { readonly status: "available"; readonly reason: null } + | { readonly status: "unknown"; readonly reason: null } + | { readonly status: "unavailable"; readonly reason: string }; + +/** + * Derive provider-neutral copy for an unavailable server shadow. + * `unavailableReason` is the actionable contract and outranks probe messages. + */ +export function getProviderUnavailablePresentation( + provider: + | Pick + | null + | undefined, +): ProviderUnavailablePresentation | null { + if (provider?.availability !== "unavailable") return null; + return { + headline: "Unavailable", + detail: + provider.unavailableReason?.trim() || + provider.message?.trim() || + "This provider is unavailable in the current environment.", + }; +} + +/** + * Resolve admission without treating a missing cold/offline snapshot as proof + * that a configured provider disappeared. Warning snapshots remain usable; + * their message is advisory, not an admission failure. + */ +type ProviderAdmissionSnapshot = Pick & + Partial< + Pick< + ServerProvider, + | "availability" + | "unavailableReason" + | "message" + | "displayName" + | "enabled" + | "installed" + | "auth" + | "status" + > + >; + +export function getProviderAdmissionAvailability(input: { + readonly provider: ProviderAdmissionSnapshot | null | undefined; + readonly instanceId?: string | undefined; + readonly providerSnapshotKnown?: boolean | undefined; +}): ProviderAdmissionAvailability { + const provider = input.provider; + if (!provider) { + if (input.providerSnapshotKnown !== true) { + return { status: "unknown", reason: null }; + } + return { + status: "unavailable", + reason: input.instanceId + ? `Provider instance '${input.instanceId}' is not configured on this environment.` + : "No provider is configured on this environment.", + }; + } + const unavailable = getProviderUnavailablePresentation(provider); + if (unavailable) return { status: "unavailable", reason: unavailable.detail }; + const name = provider.displayName?.trim() || String(provider.instanceId); + if (provider.enabled === false) { + return { status: "unavailable", reason: `${name} is disabled in provider settings.` }; + } + if (provider.installed === false) { + return { status: "unavailable", reason: `${name} is not installed on this environment.` }; + } + if (provider.auth?.status === "unauthenticated") { + return { status: "unavailable", reason: `Sign in to ${name} before sending.` }; + } + if (provider.status === "error" || provider.status === "disabled") { + return { + status: "unavailable", + reason: provider.message?.trim() || `${name} is not ready to accept a turn.`, + }; + } + return { status: "available", reason: null }; +} + +/** Explain why an exact provider instance cannot accept a new turn. */ +export function getProviderAdmissionUnavailableReason(input: { + readonly provider: ProviderAdmissionSnapshot | null | undefined; + readonly instanceId?: string | undefined; + readonly providerSnapshotKnown?: boolean | undefined; +}): string | null { + return getProviderAdmissionAvailability(input).reason; +} diff --git a/packages/client-runtime/src/providerContinuation.test.ts b/packages/client-runtime/src/providerContinuation.test.ts new file mode 100644 index 000000000..1ed1dad76 --- /dev/null +++ b/packages/client-runtime/src/providerContinuation.test.ts @@ -0,0 +1,110 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveProviderContinuationTransition } from "./providerContinuation.ts"; + +function provider(input: { + readonly instanceId: string; + readonly driver: string; + readonly continuationGroupKey?: string; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...(input.continuationGroupKey === undefined + ? {} + : { continuation: { groupKey: input.continuationGroupKey } }), + }; +} + +const id = ProviderInstanceId.make; + +describe("resolveProviderContinuationTransition", () => { + it("accepts the exact current instance without continuation metadata", () => { + expect( + resolveProviderContinuationTransition({ + providers: [], + currentInstanceId: id("codex"), + targetInstanceId: id("codex"), + }), + ).toEqual({ compatible: true }); + }); + + it("accepts only same-driver instances with an exact non-empty continuation identity", () => { + const providers = [ + provider({ instanceId: "codex", driver: "codex", continuationGroupKey: "codex:home:a" }), + provider({ + instanceId: "codex_personal", + driver: "codex", + continuationGroupKey: "codex:home:a", + }), + provider({ + instanceId: "codex_other", + driver: "codex", + continuationGroupKey: "codex:home:b", + }), + provider({ + instanceId: "claude", + driver: "claudeAgent", + continuationGroupKey: "codex:home:a", + }), + ]; + + expect( + resolveProviderContinuationTransition({ + providers, + currentInstanceId: id("codex"), + targetInstanceId: id("codex_personal"), + }), + ).toEqual({ compatible: true }); + expect( + resolveProviderContinuationTransition({ + providers, + currentInstanceId: id("codex"), + targetInstanceId: id("codex_other"), + }), + ).toMatchObject({ compatible: false }); + expect( + resolveProviderContinuationTransition({ + providers, + currentInstanceId: id("codex"), + targetInstanceId: id("claude"), + }), + ).toMatchObject({ compatible: false }); + }); + + it("rejects unresolved and missing continuation identities with a concrete reason", () => { + const providers = [ + provider({ instanceId: "codex", driver: "codex" }), + provider({ instanceId: "codex_personal", driver: "codex" }), + ]; + const unresolved = resolveProviderContinuationTransition({ + providers, + currentInstanceId: id("missing"), + targetInstanceId: id("codex"), + }); + const unproven = resolveProviderContinuationTransition({ + providers, + currentInstanceId: id("codex"), + targetInstanceId: id("codex_personal"), + }); + + expect(unresolved).toMatchObject({ + compatible: false, + reason: expect.stringContaining("cannot be resolved"), + }); + expect(unproven).toMatchObject({ + compatible: false, + reason: expect.stringContaining("does not prove"), + }); + }); +}); diff --git a/packages/client-runtime/src/providerContinuation.ts b/packages/client-runtime/src/providerContinuation.ts new file mode 100644 index 000000000..4bceaba91 --- /dev/null +++ b/packages/client-runtime/src/providerContinuation.ts @@ -0,0 +1,64 @@ +import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; + +export type ProviderContinuationTransition = + | { readonly compatible: true } + | { readonly compatible: false; readonly reason: string }; + +function continuationGroupKey(provider: ServerProvider | undefined): string | null { + const key = provider?.continuation?.groupKey?.trim(); + return key && key.length > 0 ? key : null; +} + +/** + * Existing sessions may move only between exact provider continuation peers. + * Same-instance selection is always valid; cross-instance selection needs the + * same driver and the same explicit, non-empty continuation group. + */ +export function resolveProviderContinuationTransition(input: { + readonly providers: ReadonlyArray; + readonly currentInstanceId: ProviderInstanceId; + readonly targetInstanceId: ProviderInstanceId; +}): ProviderContinuationTransition { + if (input.currentInstanceId === input.targetInstanceId) { + return { compatible: true }; + } + const current = input.providers.find( + (provider) => provider.instanceId === input.currentInstanceId, + ); + if (!current) { + return { + compatible: false, + reason: `The thread binding '${input.currentInstanceId}' cannot be resolved on this environment.`, + }; + } + const target = input.providers.find((provider) => provider.instanceId === input.targetInstanceId); + if (!target) { + return { + compatible: false, + reason: `Provider instance '${input.targetInstanceId}' is not configured on this environment.`, + }; + } + if (current.driver !== target.driver) { + return { + compatible: false, + reason: `This thread uses ${current.displayName ?? current.driver}. Start a new thread to change providers.`, + }; + } + const currentGroup = continuationGroupKey(current); + const targetGroup = continuationGroupKey(target); + if (currentGroup === null || targetGroup === null) { + return { + compatible: false, + reason: + "This provider does not prove a shared continuation identity. Start a new thread to use this account.", + }; + } + if (currentGroup !== targetGroup) { + return { + compatible: false, + reason: + "This account does not share the thread's provider continuation identity. Start a new thread to use it.", + }; + } + return { compatible: true }; +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 343412e53..7e90b96e3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -377,6 +377,16 @@ export const OrchestrationSession = Schema.Struct({ activeTurnRequestId: Schema.optional(CommandId), /** Failed admission lineage retained until an exact retry or explicit stop. */ failedTurnRequestId: Schema.optional(CommandId), + /** Durable marker for an exact provider stop that still needs cleanup. */ + pendingStopRequestId: Schema.optional(CommandId), + /** Provider binding captured by the pending stop. Null means no binding existed yet. */ + pendingStopProviderInstanceId: Schema.optional(Schema.NullOr(ProviderInstanceId)), + /** Runtime incarnation captured by the pending stop. Null covers pre-bind admission. */ + pendingStopSessionIncarnationId: Schema.optional(Schema.NullOr(RuntimeSessionId)), + /** Admission lineage captured by the pending stop. */ + pendingStopTurnRequestId: Schema.optional(Schema.NullOr(CommandId)), + /** Active orchestration turn captured by the pending stop. */ + pendingStopTurnId: Schema.optional(Schema.NullOr(TurnId)), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), updatedAt: IsoDateTime, @@ -1255,18 +1265,27 @@ const ThreadSessionApplyLifecycleCommand = Schema.Struct({ expectedActiveTurnRequestId: Schema.NullOr(CommandId), expectedActiveTurnId: Schema.NullOr(TurnId), expectedFailedTurnRequestId: Schema.NullOr(CommandId), + expectedPendingStopRequestId: Schema.optional(Schema.NullOr(CommandId)), + expectedPendingStopSessionIncarnationId: Schema.optional(Schema.NullOr(RuntimeSessionId)), allowFailedTurnRequestClear: Schema.optionalKey(Schema.Literal(true)), session: OrchestrationSession, createdAt: IsoDateTime, }); -/** Atomically binds a provider session only while the exact admission is current. */ +/** + * Atomically binds a provider session and its accepted provider-shaped settings + * only while the exact admission and previous binding are current. + */ const ThreadSessionBindPendingCommand = Schema.Struct({ type: Schema.Literal("thread.session.bind-pending"), commandId: CommandId, threadId: ThreadId, requestId: CommandId, messageId: MessageId, + expectedProviderInstanceId: Schema.NullOr(ProviderInstanceId), + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, session: OrchestrationSession, createdAt: IsoDateTime, }); @@ -1573,10 +1592,20 @@ export const ThreadMessageSentPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +const ThreadTurnAdmissionIntent = Schema.Struct({ + kind: Schema.Literals(["steer", "start", "compatible-transition"]), + expectedProviderInstanceId: Schema.NullOr(ProviderInstanceId), + expectedSessionIncarnationId: Schema.NullOr(RuntimeSessionId), + expectedActiveTurnRequestId: Schema.NullOr(CommandId), + targetModelSelection: ModelSelection, +}); + export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), + /** Decision-time admission plan. Optional only for historical persisted events. */ + admissionIntent: Schema.optional(ThreadTurnAdmissionIntent), titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), interactionMode: ProviderInteractionMode.pipe( @@ -1638,6 +1667,16 @@ export const ThreadRevertedPayload = Schema.Struct({ export const ThreadSessionStopRequestedPayload = Schema.Struct({ threadId: ThreadId, + /** Exact provider binding observed when the stop was decided. */ + targetProviderInstanceId: Schema.optional(Schema.NullOr(ProviderInstanceId)), + /** Exact provider runtime incarnation observed when the stop was decided. */ + targetSessionIncarnationId: Schema.optional(Schema.NullOr(RuntimeSessionId)), + /** Exact pending session lineage observed when the stop was decided. */ + targetPendingTurnSessionId: Schema.optional(Schema.NullOr(RuntimeSessionId)), + /** Exact pending or active turn admission owned by this stop. */ + targetTurnRequestId: Schema.optional(Schema.NullOr(CommandId)), + /** Exact active orchestration turn owned by this stop. */ + targetTurnId: Schema.optional(Schema.NullOr(TurnId)), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index d057b7a3d..73485a39c 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -111,6 +111,14 @@ export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; export const ProviderStopSessionInput = Schema.Struct({ threadId: ThreadId, + /** Optional exact target. Omitted callers retain the legacy stop-current behavior. */ + expectedProviderInstanceId: Schema.optional(Schema.NullOr(ProviderInstanceId)), + expectedSessionIncarnationId: Schema.optional(Schema.NullOr(RuntimeSessionId)), + expectedAdmissionRequestId: Schema.optional(Schema.NullOr(CommandId)), + /** Quarantine cleanup removes the exact runtime directory row instead of tombstoning it. */ + removeBinding: Schema.optional(Schema.Boolean), + /** Exact quarantine must not invalidate a newer start reservation. */ + invalidateStartReservation: Schema.optional(Schema.Boolean), }); export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ab1be10dd..e685a2dda 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -158,15 +158,15 @@ export type ServerProviderBackend = typeof ServerProviderBackend.Type; /** * Availability of a configured provider instance from the runtime's POV. * - * - `available` — the build ships this driver and an instance is wired - * up. Default for legacy snapshots produced from the closed - * `ServerSettings.providers` map. - * - `unavailable` — the user's `ServerSettings.providerInstances` (or a - * persisted thread / session binding) references a driver this build - * doesn't ship. Common after rolling back from a fork or PR branch - * that introduced a new driver. The snapshot is preserved so the UI - * can render "missing driver" affordances and so the data round-trips - * when the user moves back to the fork. + * - `available` — this build and its current host/runtime can materialize + * the configured driver instance. Default for legacy snapshots produced + * from the closed `ServerSettings.providers` map. + * - `unavailable` — the configured driver instance cannot materialize in + * this build or on the current host/runtime. This includes drivers absent + * after a rollback as well as shipped drivers that reject the host platform + * or another runtime prerequisite. The snapshot is preserved so clients + * can show the supplied remediation without silently changing the stored + * provider choice, and so configuration round-trips when it is usable again. * * Snapshots with `availability: "unavailable"` MUST set * `installed: false` and `enabled: false`; the runtime refuses turn @@ -276,11 +276,11 @@ export const ServerProvider = Schema.Struct({ // Optional for back-compat: every legacy producer omits this field and // an absent value is interpreted as `"available"` by consumers (see // `isProviderAvailable`). New `ProviderInstanceRegistry` outputs set it - // explicitly so the UI can render unavailable shadows from - // `ServerSettings.providerInstances`. + // explicitly so clients can render unavailable shadows from configured + // instances that this build or host/runtime cannot materialize. availability: Schema.optional(ServerProviderAvailability), // Human-readable reason populated when `availability === "unavailable"`. - // Surfaces in the UI alongside the missing-driver affordance. + // Surfaces in clients alongside the unavailable-provider affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe(