merge: isolate concurrent OpenCode chat runs
This commit is contained in:
@@ -202,6 +202,7 @@ export function ProvidersSettings() {
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||
const [syncingUserModels, setSyncingUserModels] = useState(false);
|
||||
const [autoUserModelSyncAttempted, setAutoUserModelSyncAttempted] = useState(false);
|
||||
const [runtimeRefreshRequired, setRuntimeRefreshRequired] = useState(false);
|
||||
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
|
||||
const existingVendorIds = new Set(accounts.map((account) => account.vendorId));
|
||||
const displayProviders = useMemo(
|
||||
@@ -214,7 +215,10 @@ export function ProvidersSettings() {
|
||||
refreshProviderSnapshot();
|
||||
}, [refreshProviderSnapshot]);
|
||||
|
||||
const handleImportUserModelConfig = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||
const handleImportUserModelConfig = useCallback(async (options: {
|
||||
silent?: boolean;
|
||||
runtimeRefresh: 'apply' | 'defer';
|
||||
}) => {
|
||||
if (!accessToken) {
|
||||
if (!options.silent) {
|
||||
toast.error(t('aiProviders.toast.loginRequiredForSync', 'Sign in before syncing Makelore models.'));
|
||||
@@ -224,7 +228,12 @@ export function ProvidersSettings() {
|
||||
|
||||
setSyncingUserModels(true);
|
||||
try {
|
||||
await importUserModelConfig(accessToken);
|
||||
const result = await importUserModelConfig(accessToken, {
|
||||
runtimeRefresh: options.runtimeRefresh,
|
||||
});
|
||||
setRuntimeRefreshRequired(
|
||||
options.runtimeRefresh === 'defer' && result.runtimeRefreshRequired,
|
||||
);
|
||||
if (!options.silent) {
|
||||
toast.success(t('aiProviders.toast.syncedUserModels', 'Synced Makelore models'));
|
||||
}
|
||||
@@ -244,7 +253,7 @@ export function ProvidersSettings() {
|
||||
return;
|
||||
}
|
||||
setAutoUserModelSyncAttempted(true);
|
||||
void handleImportUserModelConfig({ silent: true });
|
||||
void handleImportUserModelConfig({ silent: true, runtimeRefresh: 'defer' });
|
||||
}, [
|
||||
accessToken,
|
||||
autoUserModelSyncAttempted,
|
||||
@@ -324,7 +333,7 @@ export function ProvidersSettings() {
|
||||
<Button
|
||||
data-testid="providers-sync-user-models-button"
|
||||
variant="outline"
|
||||
onClick={() => void handleImportUserModelConfig()}
|
||||
onClick={() => void handleImportUserModelConfig({ runtimeRefresh: 'apply' })}
|
||||
disabled={!accessToken || syncingUserModels}
|
||||
className="rounded-full px-4 h-9 shadow-none font-medium text-meta"
|
||||
>
|
||||
@@ -333,7 +342,7 @@ export function ProvidersSettings() {
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('aiProviders.syncUserModels', 'Sync Makelore models')}
|
||||
{t('aiProviders.syncUserModels', 'Sync and apply Makelore models')}
|
||||
</Button>
|
||||
<Button data-testid="providers-add-button" onClick={() => setShowAddDialog(true)} className="rounded-full px-5 h-9 shadow-none font-medium text-meta">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
@@ -342,6 +351,18 @@ export function ProvidersSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runtimeRefreshRequired ? (
|
||||
<p
|
||||
data-testid="providers-runtime-refresh-pending"
|
||||
className="rounded-xl border border-brand/20 bg-brand-soft px-4 py-3 text-sm text-foreground"
|
||||
>
|
||||
{t(
|
||||
'aiProviders.runtimeRefreshPending',
|
||||
'The model list is up to date. Use “Sync and apply Makelore models” when you are ready to restart the runtime.',
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground bg-surface-subtle rounded-3xl border border-transparent border-dashed">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
|
||||
@@ -824,9 +824,10 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const sessions = useOpencodeStore((state) => state.sessions);
|
||||
const sessionMessagesBySessionId = useOpencodeStore((state) => state.sessionMessagesBySessionId);
|
||||
const selectedSessionId = useOpencodeStore((state) => state.selectedSessionId);
|
||||
const selectedSessionRunError = useOpencodeStore((state) => (
|
||||
selectedSessionId ? state.sessionRunStates[selectedSessionId]?.error ?? null : null
|
||||
));
|
||||
const sessionRunStates = useOpencodeStore((state) => state.sessionRunStates);
|
||||
const selectedSessionRunError = selectedSessionId
|
||||
? sessionRunStates[selectedSessionId]?.error ?? null
|
||||
: null;
|
||||
const sessionStatuses = useOpencodeStore((state) => state.sessionStatuses);
|
||||
const sessionTranscript = useOpencodeStore((state) => (
|
||||
selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined
|
||||
@@ -950,7 +951,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const lastCompactionAnnouncementSessionRef = useRef<string | null>(null);
|
||||
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const composerStopArmTimerRef = useRef<number | null>(null);
|
||||
const userModelConfigSyncRef = useRef<Promise<void> | null>(null);
|
||||
const userModelConfigSyncRef = useRef<Promise<boolean> | null>(null);
|
||||
const localProxyModelConfigSyncedRef = useRef(false);
|
||||
const voiceRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const voiceStreamRef = useRef<MediaStream | null>(null);
|
||||
@@ -1055,8 +1056,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
&& !loading;
|
||||
const quotaExhaustedRetryActive = selectedSessionStatus?.type === 'retry'
|
||||
&& isQuotaExhaustedRetryMessage(selectedSessionStatus.message);
|
||||
const quotaExhaustedError = getOpencodeErrorKind(selectedSessionRunError) === 'quota_exhausted'
|
||||
? selectedSessionRunError
|
||||
const projectedError = error ?? selectedSessionRunError;
|
||||
const projectedErrorKind = error
|
||||
? errorKind ?? getOpencodeErrorKind(error)
|
||||
: getOpencodeErrorKind(selectedSessionRunError);
|
||||
const quotaExhaustedError = projectedErrorKind === 'quota_exhausted'
|
||||
? projectedError
|
||||
: null;
|
||||
const quotaExhaustedRetryError = quotaExhaustedRetryActive
|
||||
? selectedSessionStatus.message ?? 'quota_exhausted'
|
||||
@@ -1067,24 +1072,23 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const tokenBalanceExhaustedActive = isOpencodeTokenBalanceExhausted(activeQuotaExhaustedError);
|
||||
const authenticationInvalidRetryActive = selectedSessionStatus?.type === 'retry'
|
||||
&& getOpencodeErrorKind(selectedSessionStatus.message) === 'authentication_invalid';
|
||||
const authenticationInvalidActive = (errorKind === 'authentication_invalid' && Boolean(error))
|
||||
const authenticationInvalidActive = (projectedErrorKind === 'authentication_invalid' && Boolean(projectedError))
|
||||
|| authenticationInvalidRetryActive;
|
||||
const authenticationFailureKey = authenticationInvalidActive
|
||||
? `${selectedSessionId ?? 'global'}:${error ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
|
||||
? `${error ? 'global' : selectedSessionId ?? 'global'}:${projectedError ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
|
||||
: null;
|
||||
const quotaFailureKey = rawQuotaExhaustedActive
|
||||
? `${selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
|
||||
? `${error ? 'global' : selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
|
||||
: null;
|
||||
const quotaExhaustedErrorActive = quotaFailureKey !== null
|
||||
&& confirmedQuotaFailureKey === quotaFailureKey;
|
||||
const quotaUpgradePromptOpen = quotaFailureKey !== null
|
||||
&& quotaUpgradePromptKey === quotaFailureKey;
|
||||
const displayErrorKind = errorKind ?? getOpencodeErrorKind(error);
|
||||
const displayError = error
|
||||
&& displayErrorKind !== 'authentication_invalid'
|
||||
&& displayErrorKind !== 'quota_exhausted'
|
||||
&& !isUpstreamSaturatedError(error)
|
||||
? error
|
||||
const displayError = projectedError
|
||||
&& projectedErrorKind !== 'authentication_invalid'
|
||||
&& projectedErrorKind !== 'quota_exhausted'
|
||||
&& !isUpstreamSaturatedError(projectedError)
|
||||
? projectedError
|
||||
: null;
|
||||
const visibleTranscriptMessages = useMemo(() => {
|
||||
return buildVisibleTranscriptMessages(
|
||||
@@ -1521,21 +1525,27 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const userModelAccount = providerAccounts.find((account) => account.id === NIANCODE_USER_MODEL_ACCOUNT_ID);
|
||||
const shouldSyncLocalProxy = userModelAccount?.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE
|
||||
&& !localProxyModelConfigSyncedRef.current;
|
||||
if (!shouldRefreshWorksSquareUserModelConfig(providerAccounts) && !shouldSyncLocalProxy) return;
|
||||
if (!shouldRefreshWorksSquareUserModelConfig(providerAccounts) && !shouldSyncLocalProxy) return true;
|
||||
if (userModelConfigSyncRef.current) {
|
||||
await userModelConfigSyncRef.current;
|
||||
return;
|
||||
return await userModelConfigSyncRef.current;
|
||||
}
|
||||
|
||||
const syncTask = (async () => {
|
||||
const validAccessToken = await getValidAccessToken();
|
||||
if (!validAccessToken) return;
|
||||
if (!validAccessToken) return true;
|
||||
|
||||
try {
|
||||
await importUserModelConfig(validAccessToken);
|
||||
const result = await importUserModelConfig(validAccessToken, {
|
||||
runtimeRefresh: 'defer',
|
||||
});
|
||||
if (result.runtimeRefreshRequired) {
|
||||
toast.error('模型配置已更新,请手动重启运行时后重试。');
|
||||
return false;
|
||||
}
|
||||
if (shouldSyncLocalProxy) {
|
||||
localProxyModelConfigSyncedRef.current = true;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!isUnauthorizedWorksError(error)) {
|
||||
throw error;
|
||||
@@ -1544,25 +1554,34 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
if (!refreshedAccessToken) {
|
||||
throw error;
|
||||
}
|
||||
await importUserModelConfig(refreshedAccessToken);
|
||||
const result = await importUserModelConfig(refreshedAccessToken, {
|
||||
runtimeRefresh: 'defer',
|
||||
});
|
||||
if (result.runtimeRefreshRequired) {
|
||||
toast.error('模型配置已更新,请手动重启运行时后重试。');
|
||||
return false;
|
||||
}
|
||||
if (shouldSyncLocalProxy) {
|
||||
localProxyModelConfigSyncedRef.current = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
})()
|
||||
.catch((error) => {
|
||||
console.warn('[chat] Failed to refresh Works Square user model config', error);
|
||||
toast.error('模型配置同步失败,请稍后重试。');
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
userModelConfigSyncRef.current = null;
|
||||
});
|
||||
|
||||
userModelConfigSyncRef.current = syncTask;
|
||||
await syncTask;
|
||||
return await syncTask;
|
||||
}, [getValidAccessToken, importUserModelConfig, providerAccounts, refreshSession]);
|
||||
|
||||
const ensureChatModelConfigured = useCallback(async () => {
|
||||
await refreshWorksSquareUserModelConfigIfNeeded();
|
||||
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return false;
|
||||
if (selectedProjectAgent?.model?.trim()) return true;
|
||||
setModelConfigPromptOpen(true);
|
||||
return false;
|
||||
@@ -1611,7 +1630,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
return;
|
||||
}
|
||||
const configuredAgentModel = submissionAgent.model?.trim();
|
||||
await refreshWorksSquareUserModelConfigIfNeeded();
|
||||
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return;
|
||||
if (!sessionStillMatches()) return;
|
||||
const latestProviderState = useProviderStore.getState();
|
||||
const latestConfiguredChatModelOptions = buildConfiguredModelOptions(
|
||||
@@ -2053,6 +2072,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
}, []);
|
||||
|
||||
const runCompact = useCallback(async () => {
|
||||
if (!(await ensureChatModelConfigured())) {
|
||||
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
|
||||
}
|
||||
const owner = submissionSessionRef.current;
|
||||
const sessionId = owner.id;
|
||||
if (!sessionId) throw new Error('请先选择会话');
|
||||
@@ -2061,7 +2083,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
owner,
|
||||
submissionSessionRef.current,
|
||||
);
|
||||
}, [compactSession, selectedProjectAgent?.model]);
|
||||
}, [compactSession, ensureChatModelConfigured, selectedProjectAgent?.model]);
|
||||
|
||||
const runShare = useCallback(async () => {
|
||||
const owner = submissionSessionRef.current;
|
||||
@@ -2230,6 +2252,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
rawName: string;
|
||||
arguments: string;
|
||||
}): Promise<{ status: 'executed' | 'cancelled' }> => {
|
||||
if (!(await ensureChatModelConfigured())) {
|
||||
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
|
||||
}
|
||||
const owner = submissionSessionRef.current;
|
||||
const submissionSessionId = owner.id;
|
||||
if (!submissionSessionId) throw new Error('请先选择会话');
|
||||
@@ -2261,6 +2286,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
}, [
|
||||
composer,
|
||||
currentSession?.agent,
|
||||
ensureChatModelConfigured,
|
||||
executeProjectCommand,
|
||||
selectedProjectAgent?.id,
|
||||
selectedProjectAgent?.model,
|
||||
|
||||
@@ -812,6 +812,7 @@ export function CharacterScene() {
|
||||
const [providerSnapshotLoaded, setProviderSnapshotLoaded] = useState(false);
|
||||
const [autoUserModelSyncAttempted, setAutoUserModelSyncAttempted] = useState(false);
|
||||
const [syncingUserModels, setSyncingUserModels] = useState(false);
|
||||
const [runtimeRefreshRequired, setRuntimeRefreshRequired] = useState(false);
|
||||
const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeFile[]>(knowledgeFiles);
|
||||
const [knowledgeNotice, setKnowledgeNotice] = useState<string | null>(null);
|
||||
const [knowledgeDragActive, setKnowledgeDragActive] = useState(false);
|
||||
@@ -909,7 +910,10 @@ export function CharacterScene() {
|
||||
|
||||
setAutoUserModelSyncAttempted(true);
|
||||
setSyncingUserModels(true);
|
||||
void importUserModelConfig(accessToken)
|
||||
void importUserModelConfig(accessToken, { runtimeRefresh: 'defer' })
|
||||
.then((result) => {
|
||||
setRuntimeRefreshRequired(result.runtimeRefreshRequired);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[makelore] Failed to auto-sync current user model config', error);
|
||||
})
|
||||
@@ -940,7 +944,7 @@ export function CharacterScene() {
|
||||
|
||||
let result: Awaited<ReturnType<typeof importUserModelConfig>>;
|
||||
try {
|
||||
result = await importUserModelConfig(validAccessToken);
|
||||
result = await importUserModelConfig(validAccessToken, { runtimeRefresh: 'apply' });
|
||||
} catch (error) {
|
||||
if (!isUnauthorizedModelConfigError(error)) {
|
||||
throw error;
|
||||
@@ -949,7 +953,7 @@ export function CharacterScene() {
|
||||
if (!refreshedAccessToken) {
|
||||
throw error;
|
||||
}
|
||||
result = await importUserModelConfig(refreshedAccessToken);
|
||||
result = await importUserModelConfig(refreshedAccessToken, { runtimeRefresh: 'apply' });
|
||||
}
|
||||
|
||||
const importedModelCount = new Set([
|
||||
@@ -958,6 +962,7 @@ export function CharacterScene() {
|
||||
...result.importedModels,
|
||||
].filter(Boolean)).size;
|
||||
setProviderSnapshotLoaded(true);
|
||||
setRuntimeRefreshRequired(false);
|
||||
toast.success('已重新拉取远端模型配置', {
|
||||
description: importedModelCount > 0 ? `同步 ${importedModelCount} 个模型` : '模型列表已更新',
|
||||
});
|
||||
@@ -1253,9 +1258,17 @@ export function CharacterScene() {
|
||||
onClick={handleRefreshUserModelConfig}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', syncingUserModels && 'animate-spin')} />
|
||||
{syncingUserModels ? '重新拉取中...' : '重新拉取远端模型配置'}
|
||||
{syncingUserModels ? '应用中...' : '重新拉取并应用模型配置'}
|
||||
</Button>
|
||||
</div>
|
||||
{runtimeRefreshRequired ? (
|
||||
<p
|
||||
data-testid="makelore-runtime-refresh-pending"
|
||||
className="mb-4 rounded-lg border border-brand/20 bg-brand-soft px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
模型列表已更新。准备好重启运行时后,点击“重新拉取并应用模型配置”。
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="mb-2 font-semibold">已通过的模型</h3>
|
||||
<div className="space-y-3">
|
||||
{approvedModels.map((model) => (
|
||||
|
||||
@@ -143,6 +143,7 @@ export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt
|
||||
|
||||
switch (event.type) {
|
||||
case 'post_accepted':
|
||||
return state;
|
||||
case 'remote_busy':
|
||||
return {
|
||||
...state,
|
||||
@@ -151,6 +152,7 @@ export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt
|
||||
error: null,
|
||||
};
|
||||
case 'remote_idle':
|
||||
if (state.phase === 'posting') return state;
|
||||
return {
|
||||
...state,
|
||||
phase: 'idle',
|
||||
@@ -178,6 +180,7 @@ export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt
|
||||
return {
|
||||
...state,
|
||||
phase: 'idle',
|
||||
queue: [],
|
||||
terminalReason: 'failed',
|
||||
error: event.error,
|
||||
suppressNextAbortError: false,
|
||||
|
||||
@@ -254,6 +254,8 @@ interface OpencodeState {
|
||||
|
||||
const initialStatus: OpencodeStatus = { state: 'stopped', port: 4096 };
|
||||
const POLL_INTERVAL_MS = 750;
|
||||
const PROMPT_START_CONFIRMATION_TIMEOUT_MS = 10_000;
|
||||
const PROMPT_START_UNCONFIRMED_ERROR = 'SESSION_START_UNCONFIRMED: 运行时未确认该会话已开始,本次消息不会自动重发,请检查运行时后手动重试。';
|
||||
const IDLE_SESSION_STATUS: OpencodeSessionStatus = { type: 'idle' };
|
||||
const activeSessionEventSources = new Map<string, EventSource>();
|
||||
const activeProjectEventSources = new Map<string, EventSource>();
|
||||
@@ -277,6 +279,11 @@ function removeEventListenerIfSupported(
|
||||
let projectEventReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let projectEventReconnectAttempt = 0;
|
||||
const activeSessionRunTokens = new Map<string, number>();
|
||||
const activePromptStartWatchdogs = new Map<string, {
|
||||
runToken: number;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
reject: (reason: Error) => void;
|
||||
}>();
|
||||
const pendingSessionRunStreamBatches = new Map<string, PendingSessionRunStreamBatch>();
|
||||
const drainingAbortedSessionRunIds = new Map<string, number>();
|
||||
const ignoredSuppressedPolledAbortKeys = new Map<string, { runId: number; keys: Set<string> }>();
|
||||
@@ -307,6 +314,58 @@ type PendingSessionRunStreamBatch = {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
class PromptStartWatchdogCancelledError extends Error {}
|
||||
|
||||
function rejectPromptStartWatchdog(
|
||||
sessionId: string,
|
||||
runToken: number,
|
||||
reason: Error,
|
||||
): void {
|
||||
const watchdog = activePromptStartWatchdogs.get(sessionId);
|
||||
if (!watchdog || watchdog.runToken !== runToken) return;
|
||||
activePromptStartWatchdogs.delete(sessionId);
|
||||
clearTimeout(watchdog.timer);
|
||||
watchdog.reject(reason);
|
||||
}
|
||||
|
||||
function cancelPromptStartWatchdog(sessionId: string, runToken: number): void {
|
||||
rejectPromptStartWatchdog(
|
||||
sessionId,
|
||||
runToken,
|
||||
new PromptStartWatchdogCancelledError(),
|
||||
);
|
||||
}
|
||||
|
||||
function startPromptStartWatchdog(sessionId: string, runToken: number): Promise<never> {
|
||||
const existing = activePromptStartWatchdogs.get(sessionId);
|
||||
if (existing) cancelPromptStartWatchdog(sessionId, existing.runToken);
|
||||
|
||||
let rejectWatchdog!: (reason: Error) => void;
|
||||
const promise = new Promise<never>((_resolve, reject) => {
|
||||
rejectWatchdog = reject;
|
||||
});
|
||||
void promise.catch(() => undefined);
|
||||
const timer = setTimeout(() => {
|
||||
rejectPromptStartWatchdog(
|
||||
sessionId,
|
||||
runToken,
|
||||
new Error(PROMPT_START_UNCONFIRMED_ERROR),
|
||||
);
|
||||
}, PROMPT_START_CONFIRMATION_TIMEOUT_MS);
|
||||
activePromptStartWatchdogs.set(sessionId, {
|
||||
runToken,
|
||||
timer,
|
||||
reject: rejectWatchdog,
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function cancelAllPromptStartWatchdogs(): void {
|
||||
for (const [sessionId, watchdog] of activePromptStartWatchdogs) {
|
||||
cancelPromptStartWatchdog(sessionId, watchdog.runToken);
|
||||
}
|
||||
}
|
||||
|
||||
function beginSessionRun(sessionId: string): number {
|
||||
sessionRunNonce += 1;
|
||||
const nextToken = sessionRunNonce;
|
||||
@@ -316,6 +375,10 @@ function beginSessionRun(sessionId: string): number {
|
||||
|
||||
function invalidateSessionRun(sessionId: string): void {
|
||||
ignoredSuppressedPolledAbortKeys.delete(sessionId);
|
||||
const activeRunToken = activeSessionRunTokens.get(sessionId);
|
||||
if (activeRunToken !== undefined) {
|
||||
cancelPromptStartWatchdog(sessionId, activeRunToken);
|
||||
}
|
||||
beginSessionRun(sessionId);
|
||||
}
|
||||
|
||||
@@ -324,6 +387,7 @@ function isSessionRunCurrent(sessionId: string, token: number): boolean {
|
||||
}
|
||||
|
||||
function closeActiveSessionEventSource(): void {
|
||||
cancelAllPromptStartWatchdogs();
|
||||
activeSessionRunTokens.clear();
|
||||
for (const sessionId of pendingSessionRunStreamBatches.keys()) {
|
||||
cancelSessionRunStreamBatch(sessionId);
|
||||
@@ -2426,7 +2490,6 @@ function finishSessionRunSuccessfully(
|
||||
sendingSessionIds,
|
||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||
loading: false,
|
||||
...errorState(null),
|
||||
...(completedTranscript && completedTranscript !== currentTranscript
|
||||
? {
|
||||
sessionTranscriptBySessionId: {
|
||||
@@ -2552,7 +2615,6 @@ async function runSessionSubmission(
|
||||
...clearSessionStreamingPatch(state, sessionId),
|
||||
...runStatePatch,
|
||||
selectedSessionId,
|
||||
...errorState(null),
|
||||
sendingSessionIds,
|
||||
sendingSessionId: selectedSessionId === sessionId ? sessionId : state.sendingSessionId,
|
||||
sessionStatuses: {
|
||||
@@ -2571,6 +2633,42 @@ async function runSessionSubmission(
|
||||
});
|
||||
|
||||
let liveSessionError: Error | null = null;
|
||||
let promptStartAcknowledged = submission.kind !== 'prompt';
|
||||
let promptStartWatchdog: Promise<never> | null = null;
|
||||
const acknowledgePromptStart = () => {
|
||||
if (promptStartAcknowledged) return;
|
||||
promptStartAcknowledged = true;
|
||||
cancelPromptStartWatchdog(sessionId, runToken);
|
||||
};
|
||||
const awaitDuringPromptStart = async <T>(operation: Promise<T>): Promise<T> => {
|
||||
if (promptStartAcknowledged || !promptStartWatchdog) return await operation;
|
||||
try {
|
||||
return await Promise.race([operation, promptStartWatchdog]);
|
||||
} catch (error) {
|
||||
if (error instanceof PromptStartWatchdogCancelledError && promptStartAcknowledged) {
|
||||
return await operation;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const dispatchObservedIdle = (state: OpencodeState) => {
|
||||
const runState = getSessionRunStateForSession(state, sessionId);
|
||||
if (submission.kind === 'prompt' || runState.phase !== 'posting') {
|
||||
return dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_idle',
|
||||
runId: runToken,
|
||||
});
|
||||
}
|
||||
const startedPatch = dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_busy',
|
||||
runId: runToken,
|
||||
});
|
||||
return dispatchSessionRunEvent(
|
||||
{ ...state, ...startedPatch },
|
||||
sessionId,
|
||||
{ type: 'remote_idle', runId: runToken },
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
try {
|
||||
@@ -2586,20 +2684,32 @@ async function runSessionSubmission(
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) return;
|
||||
if (getOpencodeEventSessionId(payload) !== sessionId) return;
|
||||
const status = normalizeSessionStatus(payload.status);
|
||||
const acknowledgesStart = status.type === 'busy' || status.type === 'retry';
|
||||
if (acknowledgesStart) acknowledgePromptStart();
|
||||
if (status.type === 'idle') {
|
||||
flushSessionRunStreamBatch(set, sessionId, runToken);
|
||||
}
|
||||
set((state) => ({
|
||||
...getSessionTranscriptPatch(state, 'session.status', payload),
|
||||
...dispatchSessionRunEvent(state, sessionId, {
|
||||
type: status.type === 'idle' ? 'remote_idle' : 'remote_busy',
|
||||
runId: runToken,
|
||||
}),
|
||||
sessionStatuses: {
|
||||
...state.sessionStatuses,
|
||||
[sessionId]: status,
|
||||
},
|
||||
}));
|
||||
set((state) => {
|
||||
const runState = getSessionRunStateForSession(state, sessionId);
|
||||
const ignoreUnconfirmedIdle = submission.kind === 'prompt'
|
||||
&& status.type === 'idle'
|
||||
&& runState.phase === 'posting';
|
||||
return {
|
||||
...getSessionTranscriptPatch(state, 'session.status', payload),
|
||||
...(status.type === 'idle'
|
||||
? dispatchObservedIdle(state)
|
||||
: dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_busy',
|
||||
runId: runToken,
|
||||
})),
|
||||
sessionStatuses: {
|
||||
...state.sessionStatuses,
|
||||
[sessionId]: ignoreUnconfirmedIdle
|
||||
? state.sessionStatuses[sessionId] ?? { type: 'busy' }
|
||||
: status,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
registerSessionRunEventListener(sessionId, source, 'session.idle', (event) => {
|
||||
@@ -2607,17 +2717,21 @@ async function runSessionSubmission(
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) return;
|
||||
if (getOpencodeEventSessionId(payload) !== sessionId) return;
|
||||
flushSessionRunStreamBatch(set, sessionId, runToken);
|
||||
set((state) => ({
|
||||
...getSessionTranscriptPatch(state, 'session.idle', payload),
|
||||
...dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_idle',
|
||||
runId: runToken,
|
||||
}),
|
||||
sessionStatuses: {
|
||||
...state.sessionStatuses,
|
||||
[sessionId]: IDLE_SESSION_STATUS,
|
||||
},
|
||||
}));
|
||||
set((state) => {
|
||||
const runState = getSessionRunStateForSession(state, sessionId);
|
||||
const ignoreUnconfirmedIdle = submission.kind === 'prompt'
|
||||
&& runState.phase === 'posting';
|
||||
return {
|
||||
...getSessionTranscriptPatch(state, 'session.idle', payload),
|
||||
...dispatchObservedIdle(state),
|
||||
sessionStatuses: {
|
||||
...state.sessionStatuses,
|
||||
[sessionId]: ignoreUnconfirmedIdle
|
||||
? state.sessionStatuses[sessionId] ?? { type: 'busy' }
|
||||
: IDLE_SESSION_STATUS,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
registerSessionRunEventListener(sessionId, source, 'session.compacted', (event) => {
|
||||
@@ -2674,7 +2788,6 @@ async function runSessionSubmission(
|
||||
return {
|
||||
...transcriptPatch,
|
||||
...runStatePatch,
|
||||
...errorState(null),
|
||||
};
|
||||
}
|
||||
const sendingSessionIds = withoutKey(state.sendingSessionIds, sessionId);
|
||||
@@ -2703,7 +2816,6 @@ async function runSessionSubmission(
|
||||
sendingSessionIds,
|
||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||
loading: false,
|
||||
...errorState(null),
|
||||
};
|
||||
});
|
||||
if (shouldSuppress) return;
|
||||
@@ -2713,6 +2825,7 @@ async function runSessionSubmission(
|
||||
}
|
||||
|
||||
liveSessionError = new Error(message);
|
||||
rejectPromptStartWatchdog(sessionId, runToken, liveSessionError);
|
||||
closeSessionEventSource(sessionId);
|
||||
set((state) => {
|
||||
const transcriptPatch = getSessionTranscriptPatch(state, 'session.error', payload);
|
||||
@@ -2744,7 +2857,6 @@ async function runSessionSubmission(
|
||||
sendingSessionIds,
|
||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||
loading: false,
|
||||
...errorState(message),
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -2754,7 +2866,12 @@ async function runSessionSubmission(
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) return;
|
||||
const question = normalizeQuestionRequest(payload);
|
||||
if (!question || question.sessionID !== sessionId) return;
|
||||
acknowledgePromptStart();
|
||||
set((state) => ({
|
||||
...dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_busy',
|
||||
runId: runToken,
|
||||
}),
|
||||
pendingQuestions: mergePendingQuestion(state.pendingQuestions, question),
|
||||
}));
|
||||
});
|
||||
@@ -2794,7 +2911,12 @@ async function runSessionSubmission(
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) return;
|
||||
const permission = normalizePermissionRequest(payload);
|
||||
if (!permission || permission.sessionID !== sessionId) return;
|
||||
acknowledgePromptStart();
|
||||
set((state) => ({
|
||||
...dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_busy',
|
||||
runId: runToken,
|
||||
}),
|
||||
pendingPermissions: mergePendingPermission(state.pendingPermissions, permission),
|
||||
}));
|
||||
});
|
||||
@@ -2803,6 +2925,13 @@ async function runSessionSubmission(
|
||||
const payload = JSON.parse((event as MessageEvent<string>).data) as Record<string, unknown>;
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) return;
|
||||
if (getOpencodeEventSessionId(payload) !== sessionId) return;
|
||||
if (createStreamingMessageFromEvent(payload)?.role === 'assistant') {
|
||||
acknowledgePromptStart();
|
||||
set((state) => dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_busy',
|
||||
runId: runToken,
|
||||
}));
|
||||
}
|
||||
queueSessionRunStreamEvent(set, sessionId, runToken, 'message.updated', payload);
|
||||
});
|
||||
|
||||
@@ -2849,6 +2978,9 @@ async function runSessionSubmission(
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || `Failed to start ${submission.kind}`);
|
||||
}
|
||||
if (submission.kind === 'prompt' && !promptStartAcknowledged && !liveSessionError) {
|
||||
promptStartWatchdog = startPromptStartWatchdog(sessionId, runToken);
|
||||
}
|
||||
try {
|
||||
options?.onHostAccepted?.();
|
||||
} catch {
|
||||
@@ -2857,6 +2989,10 @@ async function runSessionSubmission(
|
||||
if (liveSessionError) {
|
||||
throw liveSessionError;
|
||||
}
|
||||
set((state) => dispatchSessionRunEvent(state, sessionId, {
|
||||
type: submission.kind === 'prompt' ? 'post_accepted' : 'remote_busy',
|
||||
runId: runToken,
|
||||
}));
|
||||
if (submission.completion === 'post-success') {
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) {
|
||||
return getSessionMessagesForState(get(), sessionId);
|
||||
@@ -2880,11 +3016,6 @@ async function runSessionSubmission(
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) {
|
||||
return getSessionMessagesForState(get(), sessionId);
|
||||
}
|
||||
set((state) => dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'post_accepted',
|
||||
runId: runToken,
|
||||
}));
|
||||
|
||||
let messages: RawMessage[] = getSessionMessagesForState(get(), sessionId);
|
||||
let status: OpencodeSessionStatus = { type: 'busy' };
|
||||
const useBaselineInference = submission.kind !== 'prompt';
|
||||
@@ -2914,7 +3045,9 @@ async function runSessionSubmission(
|
||||
|| eventStatus?.type === 'idle';
|
||||
let statuses: OpencodeSessionStatusMap = {};
|
||||
if (shouldPollStatus) {
|
||||
const statusResponse = await hostApiFetch<OpencodeSessionStatusActionResponse>('/api/opencode/sessions/status');
|
||||
const statusResponse = await awaitDuringPromptStart(
|
||||
hostApiFetch<OpencodeSessionStatusActionResponse>('/api/opencode/sessions/status'),
|
||||
);
|
||||
statuses = normalizeSessionStatusMap(statusResponse.statuses);
|
||||
nextStatusFallbackPollAt = Date.now() + SESSION_STATUS_FALLBACK_POLL_MS;
|
||||
}
|
||||
@@ -2926,7 +3059,7 @@ async function runSessionSubmission(
|
||||
|| observedStatus?.type === 'idle';
|
||||
let refreshedMessages = messages;
|
||||
if (shouldRefreshMessages) {
|
||||
refreshedMessages = await fetchSessionMessages(sessionId);
|
||||
refreshedMessages = await awaitDuringPromptStart(fetchSessionMessages(sessionId));
|
||||
nextMessageFallbackPollAt = Date.now() + SESSION_MESSAGE_FALLBACK_POLL_MS;
|
||||
}
|
||||
if (liveSessionError) {
|
||||
@@ -2952,6 +3085,13 @@ async function runSessionSubmission(
|
||||
: sendingUserMessage
|
||||
? getAssistantErrorAfterPrompt(refreshedMessages, sendingUserMessage)
|
||||
: null;
|
||||
const assistantResponded = submission.kind === 'prompt'
|
||||
&& sendingUserMessage
|
||||
&& hasAssistantResponseAfterPrompt(refreshedMessages, sendingUserMessage);
|
||||
const explicitBusy = httpStatus?.type === 'busy' || httpStatus?.type === 'retry';
|
||||
if (explicitBusy || assistantResponded) {
|
||||
acknowledgePromptStart();
|
||||
}
|
||||
const inferredStatus = useBaselineInference
|
||||
? inferMissingPolledStatusAfterBaseline(refreshedMessages, baseline)
|
||||
: sendingUserMessage
|
||||
@@ -2960,13 +3100,16 @@ async function runSessionSubmission(
|
||||
// A missing HTTP status is common while the runtime is transitioning.
|
||||
// Prefer a newly observed assistant response over the optimistic/event
|
||||
// busy marker, otherwise a completed run can remain busy forever.
|
||||
const polledStatus = submission.kind === 'compact'
|
||||
const observedPolledStatus = submission.kind === 'compact'
|
||||
? shouldPollStatus
|
||||
? httpStatus ?? eventStatus ?? status
|
||||
: eventStatus ?? status
|
||||
: shouldPollStatus
|
||||
? httpStatus ?? inferredStatus
|
||||
: eventStatus ?? status ?? inferredStatus;
|
||||
const polledStatus = submission.kind === 'prompt' && !promptStartAcknowledged
|
||||
? { type: 'busy' as const }
|
||||
: observedPolledStatus;
|
||||
status = assistantError || assistantAborted ? IDLE_SESSION_STATUS : polledStatus;
|
||||
const currentMessages = getSessionMessagesForState(get(), sessionId);
|
||||
messages = assistantAborted || status.type === 'idle'
|
||||
@@ -2996,7 +3139,6 @@ async function runSessionSubmission(
|
||||
...statuses,
|
||||
[sessionId]: polledStatus,
|
||||
},
|
||||
...errorState(null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3026,13 +3168,12 @@ async function runSessionSubmission(
|
||||
sendingSessionIds,
|
||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||
loading: false,
|
||||
...errorState(null),
|
||||
};
|
||||
});
|
||||
|
||||
if (shouldSuppressAbort) {
|
||||
rememberIgnoredSuppressedPolledAbortKey(sessionId, runToken, assistantAbortKey);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
await awaitDuringPromptStart(sleep(POLL_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3045,6 +3186,12 @@ async function runSessionSubmission(
|
||||
if (shouldRefreshMessages || shouldPollStatus || assistantError) {
|
||||
set((state) => ({
|
||||
...getSessionMessagePatch(state, sessionId, messages),
|
||||
...(promptStartAcknowledged
|
||||
? dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_busy',
|
||||
runId: runToken,
|
||||
})
|
||||
: {}),
|
||||
...(assistantError
|
||||
? dispatchSessionRunEvent(state, sessionId, {
|
||||
type: 'remote_failed',
|
||||
@@ -3057,7 +3204,6 @@ async function runSessionSubmission(
|
||||
...statuses,
|
||||
[sessionId]: status,
|
||||
},
|
||||
...errorState(assistantError ?? null),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3087,7 +3233,7 @@ async function runSessionSubmission(
|
||||
return messages;
|
||||
}
|
||||
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
await awaitDuringPromptStart(sleep(POLL_INTERVAL_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isSessionRunCurrent(sessionId, runToken)) {
|
||||
@@ -3132,12 +3278,13 @@ async function runSessionSubmission(
|
||||
sendingSessionIds,
|
||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||
loading: false,
|
||||
...errorState(errorMessage),
|
||||
};
|
||||
});
|
||||
invalidateSessionRun(sessionId);
|
||||
clearDrainingAbortedRunIfForPreviousRun(sessionId, runToken);
|
||||
throw error;
|
||||
} finally {
|
||||
cancelPromptStartWatchdog(sessionId, runToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3653,7 +3800,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
sendingSessionIds,
|
||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||
loading: true,
|
||||
...errorState(null),
|
||||
};
|
||||
});
|
||||
try {
|
||||
@@ -3692,7 +3838,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
return {
|
||||
...clearSessionRunAbortSuppression(state, sessionId),
|
||||
loading: false,
|
||||
...errorState(null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3715,7 +3860,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
sendingSessionIds,
|
||||
sendingSessionId: getSendingSessionIdForSelection(nextState),
|
||||
loading: false,
|
||||
...errorState(null),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -3731,7 +3875,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
})
|
||||
: clearSessionRunAbortSuppression(state, sessionId)),
|
||||
loading: false,
|
||||
...errorState(errorMessage),
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
@@ -4249,7 +4392,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
return {
|
||||
...getSessionMessagePatch(currentState, sessionId, messages),
|
||||
...runStatePatch,
|
||||
...errorState(null),
|
||||
};
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -38,9 +38,13 @@ interface ProviderState {
|
||||
refreshProviderSnapshot: () => Promise<void>;
|
||||
createAccount: (account: ProviderAccount, apiKey?: string) => Promise<void>;
|
||||
removeAccount: (accountId: string) => Promise<void>;
|
||||
importUserModelConfig: (accessToken: string) => Promise<{
|
||||
importUserModelConfig: (
|
||||
accessToken: string,
|
||||
options?: { runtimeRefresh?: 'apply' | 'defer' },
|
||||
) => Promise<{
|
||||
account: ProviderAccount;
|
||||
importedModels: string[];
|
||||
runtimeRefreshRequired: boolean;
|
||||
}>;
|
||||
validateAccountApiKey: (
|
||||
accountId: string,
|
||||
@@ -218,16 +222,20 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
deleteAccount: async (accountId) => get().removeAccount(accountId),
|
||||
|
||||
importUserModelConfig: async (accessToken) => {
|
||||
importUserModelConfig: async (accessToken, options) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
account?: ProviderAccount;
|
||||
importedModels?: string[];
|
||||
runtimeRefreshRequired?: boolean;
|
||||
}>('/api/provider-accounts/import-user-model-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accessToken }),
|
||||
body: JSON.stringify({
|
||||
accessToken,
|
||||
runtimeRefresh: options?.runtimeRefresh ?? 'apply',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.success || !result.account) {
|
||||
@@ -238,6 +246,7 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
return {
|
||||
account: result.account,
|
||||
importedModels: result.importedModels ?? [],
|
||||
runtimeRefreshRequired: result.runtimeRefreshRequired === true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to import current user model config:', error);
|
||||
|
||||
Reference in New Issue
Block a user