fix(opencode): avoid busy preflight warnings after compatibility

This commit is contained in:
777genius 2026-05-18 15:50:38 +03:00
parent 6e4f8ff8c4
commit 7c5832bd7e
7 changed files with 463 additions and 61 deletions

View file

@ -903,13 +903,6 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
// Facts:
// - Deep OpenCode preflight below calls adapter.prepare({ runtimeOnly: false }).
// - OpenCodeTeamRuntimeAdapter maps runtimeOnly:false to requireExecutionProbe:true.
// - The readiness bridge then runs modelExecution.verify against the OpenCode host.
// - The host reports "session status busy" when another foreground probe/request is active.
// Keep probes serial so preflight does not create its own busy failures.
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 1;
const OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS = new Set([
'not_installed',
'not_authenticated',
@ -989,11 +982,38 @@ function isRetryableOpenCodePreflightBusyDiagnostic(value: string | null | undef
);
}
function buildOpenCodeModelVerificationDeferredLine(modelId: string, reason: string): string {
function isOpenCodeModelVerificationTimeoutDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return lower.includes('model verification timed out');
}
function selectOpenCodeModelPreparePrimaryReason(
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>
): string {
const candidates = [...prepare.diagnostics, prepare.reason]
.map((entry) => entry?.trim() ?? '')
.filter(Boolean);
const timeoutReason = candidates.find(isOpenCodeModelVerificationTimeoutDiagnostic);
return timeoutReason ?? candidates[0] ?? prepare.reason;
}
function isOpenCodeModelPrepareBusyDeferred(
prepare: Extract<TeamRuntimePrepareResult, { ok: false }>,
primaryReason: string
): boolean {
const candidates = [primaryReason, prepare.reason, ...prepare.diagnostics];
return (
prepare.retryable &&
!candidates.some(isOpenCodeModelVerificationTimeoutDiagnostic) &&
candidates.some(isRetryableOpenCodePreflightBusyDiagnostic)
);
}
function buildOpenCodeProviderVerificationDeferredLine(reason: string): string {
const normalizedReason = isRetryableOpenCodePreflightBusyDiagnostic(reason)
? 'OpenCode session is busy; retry when idle.'
? 'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.'
: reason;
return `Selected model ${modelId} verification deferred. ${normalizedReason}`;
return normalizedReason;
}
function applyDistinctProvisioningMemberColors<
@ -19227,11 +19247,14 @@ export class TeamProvisioningService {
}
}
const results = new Array<{ modelId: string; prepare: TeamRuntimePrepareResult }>(
const results = new Array<{ modelId: string; prepare: TeamRuntimePrepareResult } | undefined>(
modelIds.length
);
const workerCount = Math.min(OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY, modelIds.length);
let nextIndex = 0;
let providerBusyDeferred: {
modelId: string;
reason: string;
code: string;
} | null = null;
const prepareModel = async (modelId: string): Promise<TeamRuntimePrepareResult> => {
const startedAt = Date.now();
@ -19281,26 +19304,45 @@ export class TeamProvisioningService {
}
};
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= modelIds.length) {
return;
}
// Facts:
// - Deep OpenCode preflight maps to a real foreground execution probe.
// - The host reports "session status busy" while another probe/member turn is active.
// - Once busy is observed, probing more selected models only repeats the same host state.
for (let index = 0; index < modelIds.length; index += 1) {
const modelId = modelIds[index];
const prepare = await prepareModel(modelId);
results[index] = { modelId, prepare };
const modelId = modelIds[currentIndex];
results[currentIndex] = {
modelId,
prepare: await prepareModel(modelId),
};
}
})
);
if (verificationMode === 'compatibility' || prepare.ok) {
continue;
}
const primaryReason = normalizeOpenCodePrepareDiagnostic(
selectOpenCodeModelPreparePrimaryReason(prepare),
prepare.reason
);
if (isOpenCodeModelPrepareBusyDeferred(prepare, primaryReason)) {
providerBusyDeferred = {
modelId,
reason: primaryReason,
code: prepare.reason,
};
appendPreflightDebugLog('opencode_model_prepare_batch_busy_deferred', {
cwd,
modelId,
verificationMode,
skippedModelIds: modelIds.slice(index + 1),
reason: primaryReason,
});
break;
}
}
for (const result of results) {
if (!result) {
if (providerBusyDeferred) {
continue;
}
blockingMessages.push(
'OpenCode preflight could not collect model verification results for all selected models.'
);
@ -19324,25 +19366,15 @@ export class TeamProvisioningService {
}
const primaryReason = normalizeOpenCodePrepareDiagnostic(
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason,
selectOpenCodeModelPreparePrimaryReason(prepare),
prepare.reason
);
if (
prepare.retryable &&
[primaryReason, prepare.reason, ...prepare.diagnostics].some(
isRetryableOpenCodePreflightBusyDiagnostic
)
) {
const deferredLine = buildOpenCodeModelVerificationDeferredLine(modelId, primaryReason);
warnings.push(deferredLine);
issues.push({
providerId: 'opencode',
if (isOpenCodeModelPrepareBusyDeferred(prepare, primaryReason)) {
providerBusyDeferred ??= {
modelId,
scope: 'model',
severity: 'warning',
reason: primaryReason,
code: prepare.reason,
message: primaryReason,
});
};
continue;
}
if (this.isProviderScopedOpenCodePrepareFailure(prepare, primaryReason)) {
@ -19394,6 +19426,20 @@ export class TeamProvisioningService {
}
}
if (providerBusyDeferred) {
const providerBusyLine = buildOpenCodeProviderVerificationDeferredLine(
providerBusyDeferred.reason
);
pushUniqueLine(warnings, providerBusyLine);
issues.push({
providerId: 'opencode',
scope: 'provider',
severity: 'warning',
code: providerBusyDeferred.code,
message: providerBusyLine,
});
}
appendPreflightDebugLog('opencode_model_prepare_batch_complete', {
cwd,
modelIds,

View file

@ -323,6 +323,15 @@ function buildOpenCodeAdvisoryDeepVerificationWarning(reason: string | null | un
return `OpenCode model ping was not confirmed. ${normalizedReason}`;
}
function isProviderLevelOpenCodeBusyDeepVerificationWarning(value: string): boolean {
const lower = value.trim().toLowerCase();
return (
lower.includes('opencode is currently busy') &&
lower.includes('deep model verification') &&
lower.includes('idle')
);
}
function createOpenCodeAdvisoryDeepVerificationModelResult(
providerId: TeamProviderId,
modelId: string
@ -1196,6 +1205,7 @@ export async function runProviderPrepareDiagnostics({
const structuredProviderScopedFailure =
structuredProviderScopedIssue?.message.trim() ?? null;
let handledAdvisoryDeepFailure = false;
let handledBusyDeepDeferral = false;
if (structuredProviderScopedFailure || providerScopedFailure) {
const failureReason =
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed';
@ -1231,6 +1241,24 @@ export async function runProviderPrepareDiagnostics({
}
if (
!handledAdvisoryDeepFailure &&
batchedModelResult.ready &&
!hasModelScopedEntries &&
runtimeWarnings.some(isProviderLevelOpenCodeBusyDeepVerificationWarning)
) {
runtimeDetailLines = [];
runtimeWarnings = [];
for (const modelId of compatibilityPassedModelIds) {
recordTerminalModelResult(modelId, {
status: 'ready',
line: buildModelAvailableLine(providerId, modelId),
warningLine: null,
});
}
handledBusyDeepDeferral = true;
}
if (
!handledAdvisoryDeepFailure &&
!handledBusyDeepDeferral &&
(shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
result: batchedModelResult,
modelIds: compatibilityPassedModelIds,
@ -1255,6 +1283,7 @@ export async function runProviderPrepareDiagnostics({
}
if (
!handledAdvisoryDeepFailure &&
!handledBusyDeepDeferral &&
!hasModelScopedEntries &&
compatibilityPassedModelIds.length === 1
) {
@ -1262,7 +1291,7 @@ export async function runProviderPrepareDiagnostics({
runtimeWarnings = [];
}
if (!handledAdvisoryDeepFailure) {
if (!handledAdvisoryDeepFailure && !handledBusyDeepDeferral) {
for (const modelId of compatibilityPassedModelIds) {
recordTerminalModelResult(
modelId,

View file

@ -51,12 +51,11 @@ export function buildProviderPrepareRuntimeStatusSignature(
authMethod: provider?.authMethod ?? null,
selectedBackendId: provider?.selectedBackendId ?? null,
resolvedBackendId: provider?.resolvedBackendId ?? null,
models: normalizeModelIds(provider?.models),
modelCatalogSource: provider?.modelCatalog?.source ?? null,
modelCatalogStatus: provider?.modelCatalog?.status ?? null,
modelCatalogModels: normalizeModelIds(
provider?.modelCatalog?.models?.map((model) => model.id)
),
// Facts:
// - Selected models are already represented by modelChecksSignature.
// - OpenCode/Codex live catalogs can expand while preflight is running.
// - Including catalog contents here retriggers duplicate preflights and can
// make still-running OpenCode PONG probes look like persistent busy.
connection: provider?.connection
? {
supportsOAuth: provider.connection.supportsOAuth,

View file

@ -1083,7 +1083,120 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
'Selected model opencode/nemotron-3-super-free verified for launch.',
]);
expect(result.warnings).toEqual([
'Selected model opencode/big-pickle verification deferred. OpenCode session is busy; retry when idle.',
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
]);
expect(result.issues).toEqual([
{
providerId: 'opencode',
scope: 'provider',
severity: 'warning',
code: 'provider_busy',
message:
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
},
]);
});
it('stops OpenCode deep model verification after the first busy host result', async () => {
const prepare = vi.fn(async (input: { model?: string }) => {
if (input.model === 'opencode/minimax-m2.5-free') {
return {
ok: false as const,
providerId: 'opencode' as const,
reason: 'provider_busy',
retryable: true,
diagnostics: ['OpenCode session status busy'],
warnings: [],
};
}
return {
ok: true as const,
providerId: 'opencode' as const,
modelId: input.model ?? null,
diagnostics: [],
warnings: [],
};
});
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
]);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(registry);
const result = await svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
modelIds: [
'opencode/minimax-m2.5-free',
'opencode/nemotron-3-super-free',
'opencode/big-pickle',
],
modelVerificationMode: 'deep',
});
expect(prepare).toHaveBeenCalledTimes(1);
expect(prepare).toHaveBeenCalledWith(
expect.objectContaining({
model: 'opencode/minimax-m2.5-free',
runtimeOnly: false,
})
);
expect(result.ready).toBe(true);
expect(result.details).toBeUndefined();
expect(result.warnings).toEqual([
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
]);
});
it('does not mask OpenCode model verification timeouts as busy deferred checks', async () => {
const prepare = vi.fn(async () => ({
ok: false as const,
providerId: 'opencode' as const,
reason: 'model_unavailable' as const,
retryable: true,
diagnostics: ['OpenCode session status busy', 'Model verification timed out'],
warnings: [],
}));
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare,
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
]);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(registry);
const result = await svc.prepareForProvisioning(tempRoot, {
providerId: 'opencode',
forceFresh: true,
modelIds: ['opencode/big-pickle'],
modelVerificationMode: 'deep',
});
expect(result.ready).toBe(true);
expect(result.warnings).toEqual([
'Selected model opencode/big-pickle could not be verified. Model verification timed out',
]);
expect(result.warnings?.join('\n')).not.toContain('verification deferred');
expect(result.issues).toEqual([
{
providerId: 'opencode',
modelId: 'opencode/big-pickle',
scope: 'model',
severity: 'warning',
code: 'model_unavailable',
message: 'Model verification timed out',
},
]);
});

View file

@ -1751,7 +1751,7 @@ describe('LaunchTeamDialog', () => {
});
});
it('keeps the in-flight preflight result after a same-signature rerender', async () => {
it('keeps the in-flight OpenCode preflight result when live catalog expands during rerender', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
@ -1764,11 +1764,11 @@ describe('LaunchTeamDialog', () => {
verificationState: 'verified',
modelVerificationState: 'verified',
statusMessage: 'warming up',
detailMessage: 'first render',
detailMessage: 'catalog still loading',
models: ['opencode/minimax-m2.5-free'],
modelCatalog: {
source: 'app-server',
status: 'ready',
source: 'live',
status: 'checking',
models: [{ id: 'opencode/minimax-m2.5-free' }],
},
capabilities: {
@ -1838,13 +1838,21 @@ describe('LaunchTeamDialog', () => {
authMethod: 'opencode_managed',
verificationState: 'verified',
modelVerificationState: 'verified',
statusMessage: 'still warming',
detailMessage: 'same semantic status',
models: ['opencode/minimax-m2.5-free'],
statusMessage: 'healthy',
detailMessage: 'catalog ready',
models: [
'opencode/minimax-m2.5-free',
'opencode/qwen3.6-plus-free',
'openrouter/google/gemma-4-26b-a4b-it',
],
modelCatalog: {
source: 'app-server',
source: 'live',
status: 'ready',
models: [{ id: 'opencode/minimax-m2.5-free' }],
models: [
{ id: 'opencode/minimax-m2.5-free' },
{ id: 'opencode/qwen3.6-plus-free' },
{ id: 'openrouter/google/gemma-4-26b-a4b-it' },
],
},
capabilities: {
teamLaunch: true,

View file

@ -543,6 +543,101 @@ describe('runProviderPrepareDiagnostics', () => {
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
},
});
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('treats provider-level OpenCode busy after compatibility as launch-ready', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>
>((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) =>
Promise.resolve(
modelVerificationMode === 'compatibility'
? {
ready: true,
message: 'CLI is ready to launch',
details: (selectedModels ?? []).map(
(modelId) => `Selected model ${modelId} is compatible. Deep verification pending.`
),
warnings: [],
}
: {
ready: true,
message: 'CLI is ready to launch',
warnings: [
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
],
}
)
);
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['opencode/kimi-k2.6', 'openrouter/google/gemma-4-26b-a4b-it'],
prepareProvisioning,
});
expect(result.status).toBe('ready');
expect(result.details).toEqual([
'kimi-k2.6 - available for launch',
'google/gemma-4-26b-a4b-it - available for launch',
]);
expect(result.warnings).toEqual([]);
expect(result.details.join('\n')).not.toContain('verification deferred - OpenCode session is busy');
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('treats provider-level OpenCode busy after compatibility as launch-ready for one selected model', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>
>((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) =>
Promise.resolve(
modelVerificationMode === 'compatibility'
? {
ready: true,
message: 'CLI is ready to launch',
details: (selectedModels ?? []).map(
(modelId) => `Selected model ${modelId} is compatible. Deep verification pending.`
),
warnings: [],
}
: {
ready: true,
message: 'CLI is ready to launch',
warnings: [
'OpenCode is currently busy with another session. Deep model verification will retry when OpenCode is idle.',
],
}
)
);
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['opencode/kimi-k2.6'],
prepareProvisioning,
});
expect(result.status).toBe('ready');
expect(result.details).toEqual([
'kimi-k2.6 - available for launch',
]);
expect(result.warnings).toEqual([]);
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {

View file

@ -349,6 +349,118 @@ describe('providerPrepareRequestSignature', () => {
expect(first).toBe(second);
});
it('ignores OpenCode catalog expansion that can happen while preflight is already running', () => {
const providerIds = ['opencode'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
[
'opencode',
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
models: ['opencode/minimax-m2.5-free'],
modelCatalog: {
source: 'live',
status: 'checking',
models: [{ id: 'opencode/minimax-m2.5-free' }],
},
},
],
]) as any
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
new Map([
[
'opencode',
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
models: [
'opencode/minimax-m2.5-free',
'opencode/qwen3.6-plus-free',
'openrouter/google/gemma-4-26b-a4b-it',
],
modelCatalog: {
source: 'live',
status: 'ready',
models: [
{ id: 'opencode/minimax-m2.5-free' },
{ id: 'opencode/qwen3.6-plus-free' },
{ id: 'openrouter/google/gemma-4-26b-a4b-it' },
],
},
},
],
]) as any
);
expect(first).toBe(second);
});
it('still changes the full request signature when selected OpenCode model checks change', () => {
const runtimeStatusSignature = buildProviderPrepareRuntimeStatusSignature(
['opencode'],
new Map([
[
'opencode',
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'oauth',
selectedBackendId: 'opencode-cli',
resolvedBackendId: 'opencode-cli',
models: [
'opencode/minimax-m2.5-free',
'opencode/qwen3.6-plus-free',
],
modelCatalog: {
source: 'live',
status: 'ready',
models: [
{ id: 'opencode/minimax-m2.5-free' },
{ id: 'opencode/qwen3.6-plus-free' },
],
},
},
],
]) as any
);
const first = buildProviderPrepareRequestSignature({
cwd: '/tmp/project',
selectedProviderId: 'opencode',
selectedModel: 'opencode/minimax-m2.5-free',
selectedMemberProviders: ['opencode'],
runtimeStatusSignature,
modelChecksSignature: buildProviderPrepareModelChecksSignature(
new Map([['opencode', ['opencode/minimax-m2.5-free']]])
),
});
const second = buildProviderPrepareRequestSignature({
cwd: '/tmp/project',
selectedProviderId: 'opencode',
selectedModel: 'opencode/qwen3.6-plus-free',
selectedMemberProviders: ['opencode'],
runtimeStatusSignature,
modelChecksSignature: buildProviderPrepareModelChecksSignature(
new Map([['opencode', ['opencode/qwen3.6-plus-free']]])
),
});
expect(first).not.toBe(second);
});
it('ignores live verification fields that can drift while preflight is already running', () => {
const providerIds = ['opencode'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(