fix: isolate concurrent OpenCode chat runs

This commit is contained in:
2026-08-17 22:03:37 +08:00
parent 7e8d9e3811
commit 65040730ec
29 changed files with 5207 additions and 427 deletions

View File

@@ -32,9 +32,20 @@ import { getHostApiToken } from '../server';
import {
listProjectKnowledge,
readProjectConfig,
readProjectConfigSnapshot,
writeProjectConfig,
type ProjectConfigReadResult,
} from '../../opencode/project-config';
import {
acceptProjectAgentRuntime,
mutateProjectAgentRuntime,
observeProjectAgentRuntime,
observeProjectAgentRuntimeGeneration,
} from '../../opencode/project-agent-runtime';
import {
withRuntimeAcceptanceTimeout,
withRuntimeConfigCoordinator,
} from '../../opencode/runtime-config-readiness';
import {
readProjectConversationState,
writeProjectConversationState,
@@ -59,6 +70,7 @@ const bundledCourseSkillIds = new Set<string>(BUNDLED_COURSE_SKILL_IDS);
type ActiveProjectClient = ReturnType<typeof createOpencodeClient>;
type OpencodePromptModel = NonNullable<SendOpencodeSessionMessageInput['model']>;
type RuntimeStatus = ReturnType<HostApiContext['opencodeManager']['getStatus']>;
const activeProjectPathByClient = new WeakMap<object, string>();
interface ExpectedUserModelProxyConfig {
usesLocalProxy: boolean;
@@ -256,12 +268,29 @@ async function loadRuntimeConfigSummaryForPrompt(): Promise<OpencodeRuntimeConfi
}
}
let directWorksSquareCredentialRefreshPromise: Promise<void> | null = null;
const directWorksSquareCredentialRefreshPromises = new WeakMap<
object,
Record<'apply' | 'defer', Promise<boolean> | null>
>();
async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise<void> {
function credentialRefreshPromisesForManager(
manager: object,
): Record<'apply' | 'defer', Promise<boolean> | null> {
let promises = directWorksSquareCredentialRefreshPromises.get(manager);
if (!promises) {
promises = { apply: null, defer: null };
directWorksSquareCredentialRefreshPromises.set(manager, promises);
}
return promises;
}
async function refreshDirectWorksSquareCredentialBeforePrompt(
ctx: HostApiContext,
runtimeRefresh: 'apply' | 'defer',
): Promise<boolean> {
const account = await getProviderService().getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
if (account?.metadata?.worksSquareCredentialMode !== WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
return;
return false;
}
const expiresAt = Date.parse(account.metadata.worksSquareCredentialExpiresAt ?? '');
@@ -269,7 +298,7 @@ async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContex
Number.isFinite(expiresAt)
&& expiresAt > Date.now() + DIRECT_WORKS_SQUARE_CREDENTIAL_REFRESH_SKEW_MS
) {
return;
return false;
}
const accessToken = await getValidWorksSquareAccessToken();
@@ -277,18 +306,88 @@ async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContex
throw new Error('Works Square access token unavailable; please log in again');
}
await importCurrentUserModelConfig(ctx, accessToken);
const result = await importCurrentUserModelConfig(ctx, accessToken, { runtimeRefresh });
logger.info('[opencode-route] Refreshed direct Works Square AI gateway credential before prompt');
return result.runtimeRefreshRequired;
}
async function ensureDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise<void> {
if (!directWorksSquareCredentialRefreshPromise) {
directWorksSquareCredentialRefreshPromise = refreshDirectWorksSquareCredentialBeforePrompt(ctx)
async function ensureDirectWorksSquareCredentialBeforePrompt(
ctx: HostApiContext,
runtimeRefresh: 'apply' | 'defer' = 'apply',
): Promise<boolean> {
const promises = credentialRefreshPromisesForManager(ctx.opencodeManager as object);
if (!promises[runtimeRefresh]) {
promises[runtimeRefresh] = refreshDirectWorksSquareCredentialBeforePrompt(
ctx,
runtimeRefresh,
)
.finally(() => {
directWorksSquareCredentialRefreshPromise = null;
promises[runtimeRefresh] = null;
});
}
await directWorksSquareCredentialRefreshPromise;
return await promises[runtimeRefresh];
}
async function prepareRuntimeExecution(
res: ServerResponse,
ctx: HostApiContext,
): Promise<boolean> {
try {
if (await ensureDirectWorksSquareCredentialBeforePrompt(ctx, 'defer')) {
return true;
}
} catch (error) {
logger.warn('[opencode-route] Failed to defer runtime credential refresh', error);
sendRuntimeConfigPending(res, ctx);
return false;
}
return true;
}
async function coordinateRuntimeExecution(
res: ServerResponse,
ctx: HostApiContext,
operation: (
signal: AbortSignal,
markPending: () => void,
response: ServerResponse,
) => Promise<void>,
): Promise<boolean> {
if (!await prepareRuntimeExecution(res, ctx)) return false;
return await withRuntimeConfigCoordinator(ctx.opencodeManager, async (lease) => {
if (lease.isRefreshPending()) {
sendRuntimeConfigPending(res, ctx);
return false;
}
let operationMarkedPending = false;
try {
await withRuntimeAcceptanceTimeout(async (signal) => {
const guardedResponse = new Proxy(res, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== 'function') return value;
return (...args: unknown[]) => {
if (signal.aborted) return property === 'write' ? false : undefined;
return Reflect.apply(value, target, args);
};
},
set(target, property, value, receiver) {
if (signal.aborted) return true;
return Reflect.set(target, property, value, receiver);
},
});
const markPending = () => {
operationMarkedPending = true;
lease.markRefreshPending();
};
await operation(signal, markPending, guardedResponse);
});
} catch (error) {
if (operationMarkedPending) lease.retainRefreshPending();
throw error;
}
return true;
});
}
async function resolveRuntimePromptModel(
@@ -409,9 +508,14 @@ function expectedUserModelProxyConfig(
};
}
async function rebindLocalProxyHostApiTokenIfNeeded(expectedBaseUrl: string): Promise<boolean> {
async function rebindLocalProxyHostApiTokenIfNeeded(
expectedBaseUrl: string,
markPending: () => void,
signal?: AbortSignal,
): Promise<boolean> {
const providerService = getProviderService();
const account = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
signal?.throwIfAborted();
const accountBaseUrl = normalizeRuntimeBaseUrl(account?.baseUrl);
if (!account || accountBaseUrl !== expectedBaseUrl) {
return false;
@@ -423,15 +527,18 @@ async function rebindLocalProxyHostApiTokenIfNeeded(expectedBaseUrl: string): Pr
}
const existingApiKey = await providerService.getAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID);
signal?.throwIfAborted();
if (existingApiKey === currentHostApiToken) {
return false;
}
markPending();
await providerService.updateAccount(
NIANCODE_USER_MODEL_ACCOUNT_ID,
account,
currentHostApiToken,
);
signal?.throwIfAborted();
return true;
}
@@ -439,13 +546,16 @@ async function runtimeUsesExpectedUserModelProxy(
status: RuntimeStatus,
directory: string,
expectedConfig: ExpectedUserModelProxyConfig,
signal?: AbortSignal,
): Promise<boolean> {
if (status.state !== 'running' || !status.url) return false;
const client = createOpencodeClient({
baseUrl: status.url,
directory,
});
const runtimeConfig = await client.getConfig();
signal?.throwIfAborted();
const runtimeConfig = await client.getConfig({ signal });
signal?.throwIfAborted();
const actualBaseUrl = providerBaseUrlFromRuntimeConfig(
runtimeConfig,
NIANCODE_USER_MODEL_ACCOUNT_ID,
@@ -473,6 +583,9 @@ async function ensureRuntimeUserModelProxyConfig(
status: RuntimeStatus,
directory: string,
summary?: OpencodeRuntimeConfigSummary,
allowRestart = true,
markPending: () => void = () => undefined,
signal?: AbortSignal,
): Promise<RuntimeStatus | null> {
let expectedConfig: ExpectedUserModelProxyConfig | null;
try {
@@ -480,7 +593,13 @@ async function ensureRuntimeUserModelProxyConfig(
summary ?? await buildNianCodeRuntimeConfigSummary(),
);
} catch (error) {
if (signal?.aborted) throw error;
logger.warn('[opencode-route] Failed to inspect generated runtime provider config', error);
if (!allowRestart) {
markPending();
sendRuntimeConfigPending(res, ctx);
return null;
}
return status;
}
if (!expectedConfig) return status;
@@ -488,8 +607,13 @@ async function ensureRuntimeUserModelProxyConfig(
try {
if (
expectedConfig.usesLocalProxy
&& await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl)
&& await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl, markPending, signal)
) {
if (!allowRestart) {
markPending();
sendRuntimeConfigPending(res, ctx);
return null;
}
logger.warn('[opencode-route] Restarting runtime because local AI proxy Host API token changed');
const restartedStatus = await ctx.opencodeManager.restart();
if (restartedStatus.state !== 'running' || !restartedStatus.url) {
@@ -502,17 +626,30 @@ async function ensureRuntimeUserModelProxyConfig(
status = restartedStatus;
}
} catch (error) {
if (signal?.aborted) throw error;
logger.warn('[opencode-route] Failed to rebind local AI proxy Host API token', error);
if (!allowRestart) {
markPending();
sendRuntimeConfigPending(res, ctx);
return null;
}
}
try {
if (await runtimeUsesExpectedUserModelProxy(status, directory, expectedConfig)) {
if (await runtimeUsesExpectedUserModelProxy(status, directory, expectedConfig, signal)) {
return status;
}
} catch (error) {
if (signal?.aborted) throw error;
logger.warn('[opencode-route] Failed to inspect running runtime provider config', error);
}
if (!allowRestart) {
markPending();
sendRuntimeConfigPending(res, ctx);
return null;
}
logger.warn('[opencode-route] Restarting runtime because user model provider config is stale', {
expectedModelIds: expectedConfig.modelIds,
expectedImageInputModelIds: expectedConfig.imageInputModelIds,
@@ -528,10 +665,11 @@ async function ensureRuntimeUserModelProxyConfig(
}
try {
if (await runtimeUsesExpectedUserModelProxy(restartedStatus, directory, expectedConfig)) {
if (await runtimeUsesExpectedUserModelProxy(restartedStatus, directory, expectedConfig, signal)) {
return restartedStatus;
}
} catch (error) {
if (signal?.aborted) throw error;
logger.warn('[opencode-route] Failed to inspect restarted runtime provider config', error);
}
@@ -542,6 +680,30 @@ async function ensureRuntimeUserModelProxyConfig(
return null;
}
function sendRuntimeConfigPending(res: ServerResponse, ctx: HostApiContext): void {
sendJson(res, 409, {
success: false,
error: '运行时配置已更新。请在当前回复完成后手动重启运行时再试。',
code: 'OPENCODE_RUNTIME_CONFIG_PENDING',
promptSent: false,
terminal: true,
retryable: false,
runtimeGeneration: ctx.opencodeManager.getRuntimeGeneration?.() ?? 0,
});
}
function sendAgentRegistryPending(res: ServerResponse, runtimeGeneration: number): void {
sendJson(res, 409, {
success: false,
error: '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。',
code: 'OPENCODE_AGENT_REGISTRY_PENDING',
promptSent: false,
terminal: true,
retryable: false,
runtimeGeneration,
});
}
function buildNewProjectPath(parentPath: string | undefined, projectName: string | undefined): string {
const normalizedParent = parentPath?.trim();
const normalizedName = projectName?.trim();
@@ -652,8 +814,11 @@ function sendProjectActivationFailure(
async function getValidatedActiveProjectForRuntime<TProject extends { path: string }>(
res: ServerResponse,
ctx: HostApiContext,
signal?: AbortSignal,
): Promise<TProject | null> {
signal?.throwIfAborted();
const activeProject = await ctx.opencodeProjectStore.getActiveProject() as TProject | null;
signal?.throwIfAborted();
if (!activeProject) {
sendJson(res, 409, {
success: false,
@@ -663,16 +828,25 @@ async function getValidatedActiveProjectForRuntime<TProject extends { path: stri
}
const projectCheck = await validateProjectForActivation(activeProject);
signal?.throwIfAborted();
if (!projectCheck.ok) {
sendProjectActivationFailure(res, projectCheck);
return null;
}
const config = await readProjectConfig(activeProject.path);
signal?.throwIfAborted();
if (config.status !== 'valid' || !config.config.initialized) {
sendJson(res, 409, { success: false, error: 'Project initialization is incomplete', projectStatus: 'incomplete' });
return null;
}
await observeProjectAgentRuntime(
ctx.opencodeManager,
activeProject.path,
config.config,
signal,
);
signal?.throwIfAborted();
return activeProject;
}
@@ -680,7 +854,12 @@ async function getValidatedActiveProjectForRuntime<TProject extends { path: stri
async function createClientForActiveProject(
res: ServerResponse,
ctx: HostApiContext,
options: { ensureUserModelProxyConfig?: boolean } = {},
options: {
ensureUserModelProxyConfig?: boolean;
allowRuntimeRestart?: boolean;
markRuntimeConfigPending?: () => void;
signal?: AbortSignal;
} = {},
runtimeConfigSummary?: OpencodeRuntimeConfigSummary,
): Promise<ActiveProjectClient | null> {
let status = ctx.opencodeManager.getStatus();
@@ -692,7 +871,9 @@ async function createClientForActiveProject(
return null;
}
const activeProject = await getValidatedActiveProjectForRuntime(res, ctx);
options.signal?.throwIfAborted();
const activeProject = await getValidatedActiveProjectForRuntime(res, ctx, options.signal);
options.signal?.throwIfAborted();
if (!activeProject) {
return null;
}
@@ -703,15 +884,28 @@ async function createClientForActiveProject(
status,
activeProject.path,
runtimeConfigSummary,
options.allowRuntimeRestart,
options.markRuntimeConfigPending,
options.signal,
);
options.signal?.throwIfAborted();
if (!ensuredStatus) return null;
status = ensuredStatus;
await observeProjectAgentRuntimeGeneration(
ctx.opencodeManager,
activeProject.path,
options.signal,
);
options.signal?.throwIfAborted();
}
return createOpencodeClient({
options.signal?.throwIfAborted();
const client = createOpencodeClient({
baseUrl: status.url,
directory: activeProject.path,
});
activeProjectPathByClient.set(client, activeProject.path);
return client;
}
async function getActiveProjectContext(res: ServerResponse, ctx: HostApiContext) {
@@ -1072,7 +1266,19 @@ export async function handleOpencodeRoutes(
if (!body.projectId || !body.config) throw new Error('Missing project configuration');
const project = await findProjectById(ctx, body.projectId);
if (!project) throw new Error('Project not found');
const config = await writeProjectConfig(project.path, body.config);
const config = await mutateProjectAgentRuntime(
ctx.opencodeManager,
project.path,
async () => {
const previous = await readProjectConfigSnapshot(project.path);
const saved = await writeProjectConfig(project.path, body.config);
return {
previousConfig: previous.status === 'valid' ? previous.config : null,
config: saved,
value: saved,
};
},
);
const status = ctx.opencodeManager.getStatus();
sendJson(res, 200, { success: true, config, knowledgeFiles: await listProjectKnowledge(project.path), status });
} catch (error) {
@@ -1323,29 +1529,42 @@ export async function handleOpencodeRoutes(
);
if (sessionSummarizeMatch && req.method === 'POST') {
try {
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
const client = await createClientForActiveProject(
res,
ctx,
{ ensureUserModelProxyConfig: true },
runtimeConfigSummary,
);
if (!client) return true;
const body = await parseJsonBody<{ model?: unknown }>(req);
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
if (body.model !== undefined && !explicitModel) {
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
return true;
}
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
if (!model) {
sendJson(res, 409, { success: false, error: 'No runtime model configured' });
return true;
}
const sessionID = decodeURIComponent(sessionSummarizeMatch[1]);
await client.summarizeSession(sessionID, model);
sendJson(res, 202, { success: true });
await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => {
signal.throwIfAborted();
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
signal.throwIfAborted();
const client = await createClientForActiveProject(
response,
ctx,
{
ensureUserModelProxyConfig: true,
allowRuntimeRestart: false,
markRuntimeConfigPending: markPending,
signal,
},
runtimeConfigSummary,
);
signal.throwIfAborted();
if (!client) return;
const body = await parseJsonBody<{ model?: unknown }>(req);
signal.throwIfAborted();
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
if (body.model !== undefined && !explicitModel) {
sendJson(response, 400, { success: false, error: 'Invalid model reference' });
return;
}
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
signal.throwIfAborted();
if (!model) {
sendJson(response, 409, { success: false, error: 'No runtime model configured' });
return;
}
const sessionID = decodeURIComponent(sessionSummarizeMatch[1]);
signal.throwIfAborted();
await client.summarizeSession(sessionID, model, { signal });
signal.throwIfAborted();
sendJson(response, 202, { success: true });
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
@@ -1357,15 +1576,23 @@ export async function handleOpencodeRoutes(
);
if (sessionCommandMatch && req.method === 'POST') {
try {
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
const client = await createClientForActiveProject(
res,
ctx,
{ ensureUserModelProxyConfig: true },
runtimeConfigSummary,
);
if (!client) return true;
await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => {
signal.throwIfAborted();
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
signal.throwIfAborted();
const client = await createClientForActiveProject(
response,
ctx,
{
ensureUserModelProxyConfig: true,
allowRuntimeRestart: false,
markRuntimeConfigPending: markPending,
signal,
},
runtimeConfigSummary,
);
signal.throwIfAborted();
if (!client) return;
const body = await parseJsonBody<{
command?: unknown;
arguments?: unknown;
@@ -1374,6 +1601,7 @@ export async function handleOpencodeRoutes(
variant?: unknown;
parts?: unknown;
}>(req);
signal.throwIfAborted();
const command = parseRequiredCommandName(body.command);
const argumentsText = typeof body.arguments === 'string'
&& body.arguments.length <= MAX_COMMAND_ARGUMENTS
@@ -1381,8 +1609,8 @@ export async function handleOpencodeRoutes(
: null;
const parts = normalizeCommandParts(body.parts);
if (!command || argumentsText === null || !parts) {
sendJson(res, 400, { success: false, error: 'Invalid command payload' });
return true;
sendJson(response, 400, { success: false, error: 'Invalid command payload' });
return;
}
const sessionID = decodeURIComponent(sessionCommandMatch[1]);
const agent = parseOptionalCommandIdentifier(body.agent);
@@ -1394,24 +1622,49 @@ export async function handleOpencodeRoutes(
|| (body.variant !== undefined && !variant)
);
if (invalidOptionalField) {
sendJson(res, 400, { success: false, error: 'Invalid command runtime context' });
return true;
sendJson(response, 400, { success: false, error: 'Invalid command runtime context' });
return;
}
logger.info('[opencode-route] Executing session command', {
sessionID,
command,
partCount: parts.length,
fileMimes: parts.flatMap((part) => part.type === 'file' ? [part.mime] : []),
const executeCommand = async (): Promise<void> => {
logger.info('[opencode-route] Executing session command', {
sessionID,
command,
partCount: parts.length,
fileMimes: parts.flatMap((part) => part.type === 'file' ? [part.mime] : []),
});
signal.throwIfAborted();
await client.executeSessionCommand(sessionID, {
command,
arguments: argumentsText,
...(agent ? { agent } : {}),
...(model ? { model } : {}),
...(variant ? { variant } : {}),
...(parts.length ? { parts } : {}),
}, { signal });
signal.throwIfAborted();
};
if (agent) {
const projectPath = activeProjectPathByClient.get(client);
if (!projectPath) throw new Error('Active project runtime context is unavailable');
const acceptance = await acceptProjectAgentRuntime(
ctx.opencodeManager,
projectPath,
client,
agent,
executeCommand,
signal,
);
signal.throwIfAborted();
if (!acceptance.ready) {
sendAgentRegistryPending(response, acceptance.runtimeGeneration);
return;
}
} else {
await executeCommand();
}
signal.throwIfAborted();
sendJson(response, 202, { success: true });
});
await client.executeSessionCommand(sessionID, {
command,
arguments: argumentsText,
...(agent ? { agent } : {}),
...(model ? { model } : {}),
...(variant ? { variant } : {}),
...(parts.length ? { parts } : {}),
});
sendJson(res, 202, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
@@ -1696,15 +1949,23 @@ export async function handleOpencodeRoutes(
if (sessionMessagesMatch && req.method === 'POST') {
try {
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
const client = await createClientForActiveProject(
res,
ctx,
{ ensureUserModelProxyConfig: true },
runtimeConfigSummary,
);
if (!client) return true;
await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => {
signal.throwIfAborted();
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
signal.throwIfAborted();
const client = await createClientForActiveProject(
response,
ctx,
{
ensureUserModelProxyConfig: true,
allowRuntimeRestart: false,
markRuntimeConfigPending: markPending,
signal,
},
runtimeConfigSummary,
);
signal.throwIfAborted();
if (!client) return;
const body = await parseJsonBody<{
text?: string;
files?: unknown;
@@ -1712,39 +1973,66 @@ export async function handleOpencodeRoutes(
agent?: unknown;
model?: unknown;
}>(req);
signal.throwIfAborted();
const text = typeof body.text === 'string' ? body.text.trim() : '';
if (!text) {
sendJson(res, 400, { success: false, error: 'Missing message text' });
return true;
sendJson(response, 400, { success: false, error: 'Missing message text' });
return;
}
const sessionID = decodeURIComponent(sessionMessagesMatch[1]);
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
if (body.model !== undefined && !explicitModel) {
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
return true;
sendJson(response, 400, { success: false, error: 'Invalid model reference' });
return;
}
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
signal.throwIfAborted();
const files = normalizePromptFileParts(body.files);
const userContext = typeof body.userContext === 'string' ? body.userContext.trim().slice(0, 2_000) : '';
const agent = typeof body.agent === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(body.agent.trim())
? body.agent.trim()
: '';
logger.info('[opencode-route] Starting session prompt', {
sessionID,
runtimeUrl: ctx.opencodeManager.getStatus().url ?? null,
model: promptModelLogRef(model),
textLength: text.length,
fileCount: files.length,
fileMimes: files.map((file) => file.mime),
const acceptPrompt = async (): Promise<void> => {
logger.info('[opencode-route] Starting session prompt', {
sessionID,
runtimeUrl: ctx.opencodeManager.getStatus().url ?? null,
model: promptModelLogRef(model),
textLength: text.length,
fileCount: files.length,
fileMimes: files.map((file) => file.mime),
});
signal.throwIfAborted();
await client.promptSessionAsync(sessionID, {
text,
...(userContext ? { system: userContext } : {}),
...(agent ? { agent } : {}),
...(files.length ? { files } : {}),
...(model ? { model } : {}),
}, { signal });
signal.throwIfAborted();
};
if (agent) {
const projectPath = activeProjectPathByClient.get(client);
if (!projectPath) throw new Error('Active project runtime context is unavailable');
const acceptance = await acceptProjectAgentRuntime(
ctx.opencodeManager,
projectPath,
client,
agent,
acceptPrompt,
signal,
);
signal.throwIfAborted();
if (!acceptance.ready) {
sendAgentRegistryPending(response, acceptance.runtimeGeneration);
return;
}
} else {
await acceptPrompt();
}
signal.throwIfAborted();
sendJson(response, 202, { success: true });
});
await client.promptSessionAsync(sessionID, {
text,
...(userContext ? { system: userContext } : {}),
...(agent ? { agent } : {}),
...(files.length ? { files } : {}),
...(model ? { model } : {}),
});
sendJson(res, 202, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
@@ -1758,7 +2046,10 @@ export async function handleOpencodeRoutes(
if (url.pathname === '/api/opencode/start' && req.method === 'POST') {
try {
const status = await ctx.opencodeManager.start();
const status = await withRuntimeConfigCoordinator(
ctx.opencodeManager,
async () => await ctx.opencodeManager.start(),
);
sendJson(res, 200, { success: true, status });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -1768,7 +2059,10 @@ export async function handleOpencodeRoutes(
if (url.pathname === '/api/opencode/stop' && req.method === 'POST') {
try {
await ctx.opencodeManager.stop();
await withRuntimeConfigCoordinator(
ctx.opencodeManager,
async () => await ctx.opencodeManager.stop(),
);
sendJson(res, 200, { success: true, status: ctx.opencodeManager.getStatus() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -1778,7 +2072,10 @@ export async function handleOpencodeRoutes(
if (url.pathname === '/api/opencode/restart' && req.method === 'POST') {
try {
const status = await ctx.opencodeManager.restart();
const status = await withRuntimeConfigCoordinator(
ctx.opencodeManager,
async () => await ctx.opencodeManager.restart(),
);
sendJson(res, 200, { success: true, status });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });