fix(opencode): avoid busy preflight warnings after compatibility
This commit is contained in:
parent
6e4f8ff8c4
commit
7c5832bd7e
7 changed files with 463 additions and 61 deletions
|
|
@ -903,13 +903,6 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
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([
|
const OPENCODE_PROVIDER_SCOPED_PREPARE_FAILURE_REASONS = new Set([
|
||||||
'not_installed',
|
'not_installed',
|
||||||
'not_authenticated',
|
'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)
|
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;
|
: reason;
|
||||||
return `Selected model ${modelId} verification deferred. ${normalizedReason}`;
|
return normalizedReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDistinctProvisioningMemberColors<
|
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
|
modelIds.length
|
||||||
);
|
);
|
||||||
const workerCount = Math.min(OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY, modelIds.length);
|
let providerBusyDeferred: {
|
||||||
let nextIndex = 0;
|
modelId: string;
|
||||||
|
reason: string;
|
||||||
|
code: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
const prepareModel = async (modelId: string): Promise<TeamRuntimePrepareResult> => {
|
const prepareModel = async (modelId: string): Promise<TeamRuntimePrepareResult> => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
@ -19281,26 +19304,45 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await Promise.all(
|
// Facts:
|
||||||
Array.from({ length: workerCount }, async () => {
|
// - Deep OpenCode preflight maps to a real foreground execution probe.
|
||||||
while (true) {
|
// - The host reports "session status busy" while another probe/member turn is active.
|
||||||
const currentIndex = nextIndex;
|
// - Once busy is observed, probing more selected models only repeats the same host state.
|
||||||
nextIndex += 1;
|
for (let index = 0; index < modelIds.length; index += 1) {
|
||||||
if (currentIndex >= modelIds.length) {
|
const modelId = modelIds[index];
|
||||||
return;
|
const prepare = await prepareModel(modelId);
|
||||||
}
|
results[index] = { modelId, prepare };
|
||||||
|
|
||||||
const modelId = modelIds[currentIndex];
|
if (verificationMode === 'compatibility' || prepare.ok) {
|
||||||
results[currentIndex] = {
|
continue;
|
||||||
modelId,
|
}
|
||||||
prepare: await prepareModel(modelId),
|
|
||||||
};
|
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) {
|
for (const result of results) {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
if (providerBusyDeferred) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
blockingMessages.push(
|
blockingMessages.push(
|
||||||
'OpenCode preflight could not collect model verification results for all selected models.'
|
'OpenCode preflight could not collect model verification results for all selected models.'
|
||||||
);
|
);
|
||||||
|
|
@ -19324,25 +19366,15 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryReason = normalizeOpenCodePrepareDiagnostic(
|
const primaryReason = normalizeOpenCodePrepareDiagnostic(
|
||||||
prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason,
|
selectOpenCodeModelPreparePrimaryReason(prepare),
|
||||||
prepare.reason
|
prepare.reason
|
||||||
);
|
);
|
||||||
if (
|
if (isOpenCodeModelPrepareBusyDeferred(prepare, primaryReason)) {
|
||||||
prepare.retryable &&
|
providerBusyDeferred ??= {
|
||||||
[primaryReason, prepare.reason, ...prepare.diagnostics].some(
|
|
||||||
isRetryableOpenCodePreflightBusyDiagnostic
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const deferredLine = buildOpenCodeModelVerificationDeferredLine(modelId, primaryReason);
|
|
||||||
warnings.push(deferredLine);
|
|
||||||
issues.push({
|
|
||||||
providerId: 'opencode',
|
|
||||||
modelId,
|
modelId,
|
||||||
scope: 'model',
|
reason: primaryReason,
|
||||||
severity: 'warning',
|
|
||||||
code: prepare.reason,
|
code: prepare.reason,
|
||||||
message: primaryReason,
|
};
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (this.isProviderScopedOpenCodePrepareFailure(prepare, primaryReason)) {
|
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', {
|
appendPreflightDebugLog('opencode_model_prepare_batch_complete', {
|
||||||
cwd,
|
cwd,
|
||||||
modelIds,
|
modelIds,
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,15 @@ function buildOpenCodeAdvisoryDeepVerificationWarning(reason: string | null | un
|
||||||
return `OpenCode model ping was not confirmed. ${normalizedReason}`;
|
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(
|
function createOpenCodeAdvisoryDeepVerificationModelResult(
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
modelId: string
|
modelId: string
|
||||||
|
|
@ -1196,6 +1205,7 @@ export async function runProviderPrepareDiagnostics({
|
||||||
const structuredProviderScopedFailure =
|
const structuredProviderScopedFailure =
|
||||||
structuredProviderScopedIssue?.message.trim() ?? null;
|
structuredProviderScopedIssue?.message.trim() ?? null;
|
||||||
let handledAdvisoryDeepFailure = false;
|
let handledAdvisoryDeepFailure = false;
|
||||||
|
let handledBusyDeepDeferral = false;
|
||||||
if (structuredProviderScopedFailure || providerScopedFailure) {
|
if (structuredProviderScopedFailure || providerScopedFailure) {
|
||||||
const failureReason =
|
const failureReason =
|
||||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed';
|
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed';
|
||||||
|
|
@ -1231,6 +1241,24 @@ export async function runProviderPrepareDiagnostics({
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!handledAdvisoryDeepFailure &&
|
!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({
|
(shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
|
||||||
result: batchedModelResult,
|
result: batchedModelResult,
|
||||||
modelIds: compatibilityPassedModelIds,
|
modelIds: compatibilityPassedModelIds,
|
||||||
|
|
@ -1255,6 +1283,7 @@ export async function runProviderPrepareDiagnostics({
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!handledAdvisoryDeepFailure &&
|
!handledAdvisoryDeepFailure &&
|
||||||
|
!handledBusyDeepDeferral &&
|
||||||
!hasModelScopedEntries &&
|
!hasModelScopedEntries &&
|
||||||
compatibilityPassedModelIds.length === 1
|
compatibilityPassedModelIds.length === 1
|
||||||
) {
|
) {
|
||||||
|
|
@ -1262,7 +1291,7 @@ export async function runProviderPrepareDiagnostics({
|
||||||
runtimeWarnings = [];
|
runtimeWarnings = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!handledAdvisoryDeepFailure) {
|
if (!handledAdvisoryDeepFailure && !handledBusyDeepDeferral) {
|
||||||
for (const modelId of compatibilityPassedModelIds) {
|
for (const modelId of compatibilityPassedModelIds) {
|
||||||
recordTerminalModelResult(
|
recordTerminalModelResult(
|
||||||
modelId,
|
modelId,
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,11 @@ export function buildProviderPrepareRuntimeStatusSignature(
|
||||||
authMethod: provider?.authMethod ?? null,
|
authMethod: provider?.authMethod ?? null,
|
||||||
selectedBackendId: provider?.selectedBackendId ?? null,
|
selectedBackendId: provider?.selectedBackendId ?? null,
|
||||||
resolvedBackendId: provider?.resolvedBackendId ?? null,
|
resolvedBackendId: provider?.resolvedBackendId ?? null,
|
||||||
models: normalizeModelIds(provider?.models),
|
// Facts:
|
||||||
modelCatalogSource: provider?.modelCatalog?.source ?? null,
|
// - Selected models are already represented by modelChecksSignature.
|
||||||
modelCatalogStatus: provider?.modelCatalog?.status ?? null,
|
// - OpenCode/Codex live catalogs can expand while preflight is running.
|
||||||
modelCatalogModels: normalizeModelIds(
|
// - Including catalog contents here retriggers duplicate preflights and can
|
||||||
provider?.modelCatalog?.models?.map((model) => model.id)
|
// make still-running OpenCode PONG probes look like persistent busy.
|
||||||
),
|
|
||||||
connection: provider?.connection
|
connection: provider?.connection
|
||||||
? {
|
? {
|
||||||
supportsOAuth: provider.connection.supportsOAuth,
|
supportsOAuth: provider.connection.supportsOAuth,
|
||||||
|
|
|
||||||
|
|
@ -1083,7 +1083,120 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
'Selected model opencode/nemotron-3-super-free verified for launch.',
|
||||||
]);
|
]);
|
||||||
expect(result.warnings).toEqual([
|
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',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliStatus = {
|
storeState.cliStatus = {
|
||||||
flavor: 'agent_teams_orchestrator',
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
|
@ -1764,11 +1764,11 @@ describe('LaunchTeamDialog', () => {
|
||||||
verificationState: 'verified',
|
verificationState: 'verified',
|
||||||
modelVerificationState: 'verified',
|
modelVerificationState: 'verified',
|
||||||
statusMessage: 'warming up',
|
statusMessage: 'warming up',
|
||||||
detailMessage: 'first render',
|
detailMessage: 'catalog still loading',
|
||||||
models: ['opencode/minimax-m2.5-free'],
|
models: ['opencode/minimax-m2.5-free'],
|
||||||
modelCatalog: {
|
modelCatalog: {
|
||||||
source: 'app-server',
|
source: 'live',
|
||||||
status: 'ready',
|
status: 'checking',
|
||||||
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|
@ -1838,13 +1838,21 @@ describe('LaunchTeamDialog', () => {
|
||||||
authMethod: 'opencode_managed',
|
authMethod: 'opencode_managed',
|
||||||
verificationState: 'verified',
|
verificationState: 'verified',
|
||||||
modelVerificationState: 'verified',
|
modelVerificationState: 'verified',
|
||||||
statusMessage: 'still warming',
|
statusMessage: 'healthy',
|
||||||
detailMessage: 'same semantic status',
|
detailMessage: 'catalog ready',
|
||||||
models: ['opencode/minimax-m2.5-free'],
|
models: [
|
||||||
|
'opencode/minimax-m2.5-free',
|
||||||
|
'opencode/qwen3.6-plus-free',
|
||||||
|
'openrouter/google/gemma-4-26b-a4b-it',
|
||||||
|
],
|
||||||
modelCatalog: {
|
modelCatalog: {
|
||||||
source: 'app-server',
|
source: 'live',
|
||||||
status: 'ready',
|
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: {
|
capabilities: {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
|
|
|
||||||
|
|
@ -543,6 +543,101 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
|
'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 () => {
|
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,118 @@ describe('providerPrepareRequestSignature', () => {
|
||||||
expect(first).toBe(second);
|
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', () => {
|
it('ignores live verification fields that can drift while preflight is already running', () => {
|
||||||
const providerIds = ['opencode'] as const;
|
const providerIds = ['opencode'] as const;
|
||||||
const first = buildProviderPrepareRuntimeStatusSignature(
|
const first = buildProviderPrepareRuntimeStatusSignature(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue