fix(team): stabilize provider live smoke
This commit is contained in:
parent
5312a137da
commit
3d0ee71d09
8 changed files with 110 additions and 11 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue