fix(team): stabilize provider live smoke

This commit is contained in:
777genius 2026-05-20 01:32:39 +03:00
parent 5312a137da
commit 3d0ee71d09
8 changed files with 110 additions and 11 deletions

View file

@ -11,6 +11,9 @@ const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000;
export async function preflightOpenCodeLiveEnvironment(input) {
const repoRoot = input.repoRoot;
const requiredModels = Array.isArray(input.requiredModels)
? input.requiredModels.map((model) => String(model).trim()).filter(Boolean)
: [];
const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode';
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
const xdgDataHome = path.join(tempRoot, 'xdg-data');
@ -29,6 +32,14 @@ export async function preflightOpenCodeLiveEnvironment(input) {
if (!models.ok) {
return skip(`opencode models failed: ${models.output}`);
}
const missingModels = findMissingOpenCodeModels(models.output, requiredModels);
if (missingModels.length > 0) {
return skip(
`opencode models missing selected model(s): ${missingModels.join(', ')}. Available: ${compactOutput(
parseOpenCodeModels(models.output).join(', ') || 'none'
)}`
);
}
const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env);
if (!agents.ok) {
@ -68,7 +79,7 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) {
maxBuffer: 256_000,
});
if (result.status === 0) {
return { ok: true, output: '' };
return { ok: true, output: result.stdout || '' };
}
return {
ok: false,
@ -76,6 +87,19 @@ function runOpenCodeCommand(opencodeBin, args, cwd, env) {
};
}
function parseOpenCodeModels(output) {
return output
.split(/\s+/)
.map((model) => model.trim())
.filter(Boolean);
}
function findMissingOpenCodeModels(output, requiredModels) {
if (requiredModels.length === 0) return [];
const available = new Set(parseOpenCodeModels(output));
return requiredModels.filter((model) => !available.has(model));
}
function canBindLoopback() {
return new Promise((resolve) => {
const server = net.createServer();
@ -287,7 +311,9 @@ function compactOutput(value) {
}
export const __opencodeLivePreflightTestHooks = {
findMissingOpenCodeModels,
isHealthyOpenCodeHostResponse,
parseOpenCodeModels,
stopChild,
taskkillProcessTree,
};

View file

@ -12,6 +12,7 @@ import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight.
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
const requestedOrder =
process.env.PROVIDER_LAUNCH_STRESS_ORDER?.trim() || 'anthropic,codex,opencode,mixed';
@ -28,6 +29,8 @@ const env = {
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000',
CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS:
process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000',
PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL:
process.env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL?.trim() || DEFAULT_OPENCODE_MODEL,
OPENCODE_E2E: '1',
OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1',
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
@ -47,7 +50,7 @@ console.log(`Anthropic auth: ${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH}`);
console.log(
`Models: anthropic=${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_MODEL || 'haiku'}, codex=${
env.PROVIDER_LAUNCH_STRESS_CODEX_MODEL || 'gpt-5.4-mini'
}, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL || 'openai/gpt-5.4-mini'}`
}, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL}`
);
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
@ -109,7 +112,10 @@ async function preflightProviderLaunchStress(input) {
anthropic: needs.anthropic ? await preflightAnthropic(input.repoRoot) : { ok: true },
codex: needs.codex ? preflightCodex() : { ok: true },
opencode: needs.opencode
? await preflightOpenCodeLiveEnvironment({ repoRoot: input.repoRoot })
? await preflightOpenCodeLiveEnvironment({
repoRoot: input.repoRoot,
requiredModels: [env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL],
})
: { ok: true },
};
const skipped = [];

View file

@ -123,6 +123,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
providerId: TeamProviderId;
baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[];
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
settingsDirectory?: string | null;
}): Promise<TeamRuntimeSettingsBundle | null> {
const fragments = [...(input.baseSettings ?? [])].filter(
(fragment): fragment is TeamRuntimeSettingsJson =>
@ -145,7 +146,7 @@ export async function materializeTeamRuntimeSettingsBundle(input: {
return null;
}
const baseDirectory = input.anthropicHelper?.directory;
const baseDirectory = input.anthropicHelper?.directory ?? input.settingsDirectory;
if (!baseDirectory) {
return null;
}

View file

@ -1588,6 +1588,18 @@ function buildAnthropicSettingsArgs(
return ['--settings', JSON.stringify(settings)];
}
function sanitizeRuntimeSettingsTeamName(teamName: string): string {
return teamName.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'team';
}
function buildRuntimeSettingsTempDirectory(teamName: string): string {
return path.join(
os.tmpdir(),
'agent-teams-runtime-settings',
`${sanitizeRuntimeSettingsTeamName(teamName)}-${randomUUID()}`
);
}
function buildProviderFastModeArgs(
providerId: TeamProviderId,
launchIdentity?: ProviderModelLaunchIdentity | null
@ -7124,7 +7136,7 @@ export class TeamProvisioningService {
const rawProviderArgs = input.envResolution.providerArgs ?? [];
const rawExtraArgs = input.extraArgs ?? [];
if (!helper) {
if (!helper && resolvedProviderId !== 'anthropic') {
return {
settingsArgs: [],
fastModeArgs: buildProviderFastModeArgs(resolvedProviderId, input.launchIdentity),
@ -7137,13 +7149,14 @@ export class TeamProvisioningService {
const providerArgsWithoutHelper = filterOutSettingsPathArgs(
rawProviderArgs,
helper.settingsPath
helper?.settingsPath
);
const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper);
const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs);
if (
hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs)
helper &&
(hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) ||
hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs))
) {
throw new Error(
`${input.contextLabel}: app-managed Anthropic API-key helper cannot be combined with path-based --settings. Use inline JSON settings or remove the custom --settings path.`
@ -7160,6 +7173,7 @@ export class TeamProvisioningService {
...splitExtraArgs.settingsFragments,
],
anthropicHelper: helper,
settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName),
});
return {

View file

@ -40,7 +40,7 @@ const liveDescribe =
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
liveDescribe('Mixed provider team launch live e2e', () => {
let tempDir: string;

View file

@ -42,7 +42,7 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_CODEX_EFFORT = 'low' as const;
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'opencode/big-pickle';
const DEFAULT_ORDER: ProviderLaunchStressScenario[] = ['anthropic', 'codex', 'opencode', 'mixed'];
const MEMBER_NAMES = [
'alice',

View file

@ -2864,6 +2864,42 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBe('/tmp/runtime-hooks');
});
it('materializes Anthropic turn-settled hook settings instead of passing inline JSON', async () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledHookSettingsProvider(async ({ provider }) =>
provider === 'claude'
? {
hooks: {
Stop: [
{
matcher: '',
hooks: [{ type: 'command', command: '/bin/true # test-hook' }],
},
],
},
}
: null
);
const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({
teamName: 'anthropic-hook-settings-team',
providerId: 'anthropic',
launchIdentity: null,
envResolution: { providerArgs: [] },
extraArgs: [],
includeAnthropicHelper: false,
contextLabel: 'Team launch',
});
expect(result.fastModeArgs).toEqual([]);
expect(result.runtimeTurnSettledHookArgs).toEqual([]);
expect(result.settingsArgs[0]).toBe('--settings');
const settingsPath = result.settingsArgs[1];
expect(settingsPath).toContain('agent-teams-runtime-settings');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.hooks.Stop[0].hooks[0].command).toBe('/bin/true # test-hook');
});
it('adds Codex turn-settled env when Codex is only a secondary member provider', async () => {
const svc = new TeamProvisioningService();
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>

View file

@ -5,7 +5,6 @@ import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { afterEach, describe, expect, it, vi } from 'vitest';
interface StopChildOptions {
@ -17,7 +16,9 @@ interface StopChildOptions {
interface OpenCodeLivePreflightTestHooks {
__opencodeLivePreflightTestHooks: {
findMissingOpenCodeModels(output: string, requiredModels: string[]): string[];
isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean;
parseOpenCodeModels(output: string): string[];
stopChild(child: FakeChild, options?: StopChildOptions): Promise<void>;
taskkillProcessTree(pid: number): Promise<void>;
};
@ -47,6 +48,21 @@ describe('opencode live preflight cleanup', () => {
expect(isHealthyOpenCodeHostResponse({ ok: false })).toBe(false);
});
it('detects selected OpenCode models missing from preflight output', async () => {
const { findMissingOpenCodeModels, parseOpenCodeModels } = (await loadTestHooks())
.__opencodeLivePreflightTestHooks;
const output = 'opencode/big-pickle\nopencode/minimax-m2.5-free\n';
expect(parseOpenCodeModels(output)).toEqual([
'opencode/big-pickle',
'opencode/minimax-m2.5-free',
]);
expect(findMissingOpenCodeModels(output, ['opencode/big-pickle'])).toEqual([]);
expect(findMissingOpenCodeModels(output, ['openai/gpt-5.4-mini'])).toEqual([
'openai/gpt-5.4-mini',
]);
});
it('waits for child close after Windows process-tree cleanup', async () => {
const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks;
const child = new FakeChild({ pid: 1234 });