fix(opencode): preserve command preflight context

This commit is contained in:
777genius 2026-05-19 23:19:34 +03:00
parent d5894c029d
commit 933e580d03
9 changed files with 342 additions and 32 deletions

View file

@ -1,27 +1,27 @@
{
"version": "0.0.42",
"sourceRef": "v0.0.42",
"version": "0.0.43",
"sourceRef": "v0.0.43",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v2.0.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.42.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.43.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.42.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.43.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.42.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.43.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.42.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.43.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -7,6 +7,7 @@ import path from 'node:path';
const CHILD_CLOSE_GRACE_MS = 3_000;
const CHILD_FORCE_CLOSE_GRACE_MS = 1_000;
const TASKKILL_TIMEOUT_MS = 5_000;
const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000;
export async function preflightOpenCodeLiveEnvironment(input) {
const repoRoot = input.repoRoot;
@ -125,13 +126,12 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) {
return { ok: false, reason: output || `process exited with code ${child.exitCode}` };
}
try {
const response = await fetch(`http://127.0.0.1:${port}/global/health`);
if (response.ok) {
const data = await response.json().catch(() => ({}));
if (data?.healthy === true) {
return { ok: true };
}
const response = await fetchOpenCodeHealth(port);
if (isHealthyOpenCodeHostResponse(response)) {
response.body?.cancel().catch(() => undefined);
return { ok: true };
}
response.body?.cancel().catch(() => undefined);
} catch {
// Host is still starting.
}
@ -143,6 +143,22 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) {
}
}
async function fetchOpenCodeHealth(port) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OPENCODE_HEALTH_FETCH_TIMEOUT_MS);
try {
return await fetch(`http://127.0.0.1:${port}/global/health`, {
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
function isHealthyOpenCodeHostResponse(response) {
return response.ok;
}
async function stopChild(child, options = {}) {
const platform = options.platform ?? process.platform;
const killProcessTree = options.killProcessTree ?? taskkillProcessTree;
@ -271,6 +287,7 @@ function compactOutput(value) {
}
export const __opencodeLivePreflightTestHooks = {
isHealthyOpenCodeHostResponse,
stopChild,
taskkillProcessTree,
};

View file

@ -24,6 +24,10 @@ const env = {
PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH:
process.env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH?.trim() ||
(process.env.ANTHROPIC_API_KEY?.trim() ? 'api-key' : 'subscription'),
CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS:
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',
OPENCODE_E2E: '1',
OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1',
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',

View file

@ -162,6 +162,7 @@ export class OpenCodeBridgeCommandClient {
body,
};
const inputPath = await this.writeInputFile(envelope);
const outputPath = `${inputPath}.output.json`;
try {
const maxAttempts =
@ -169,13 +170,22 @@ export class OpenCodeBridgeCommandClient {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const processResult = await this.processRunner.run({
binaryPath: this.binaryPath,
args: ['runtime', 'opencode-command', '--json', '--input', inputPath],
args: [
'runtime',
'opencode-command',
'--json',
'--input',
inputPath,
'--output',
outputPath,
],
cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd),
timeoutMs: options.timeoutMs,
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
env: await this.resolveEnv(),
});
const stdout = await this.readBridgeOutput(processResult.stdout, outputPath);
if (processResult.timedOut) {
return this.contractFailure(
@ -204,7 +214,7 @@ export class OpenCodeBridgeCommandClient {
);
}
const parsed = parseSingleBridgeJsonResult<TData>(processResult.stdout);
const parsed = parseSingleBridgeJsonResult<TData>(stdout);
if (!parsed.ok) {
if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) {
await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS);
@ -212,7 +222,7 @@ export class OpenCodeBridgeCommandClient {
}
return this.contractFailure(envelope, 'contract_violation', parsed.error, false, {
stdoutPreview: redactBridgeDiagnosticText(processResult.stdout.slice(0, 2_000)),
stdoutPreview: redactBridgeDiagnosticText(stdout.slice(0, 2_000)),
stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)),
attempts: attempt,
});
@ -239,6 +249,18 @@ export class OpenCodeBridgeCommandClient {
if (!this.keepInputFile) {
await fs.unlink(inputPath).catch(() => undefined);
}
await fs.unlink(outputPath).catch(() => undefined);
}
}
private async readBridgeOutput(stdout: string, outputPath: string): Promise<string> {
if (stdout.trim().length > 0) {
return stdout;
}
try {
return await fs.readFile(outputPath, 'utf8');
} catch {
return stdout;
}
}

View file

@ -14,6 +14,19 @@ type SelectedModelChecksByProvider = ReadonlyMap<
readonly ProviderModelCheckSignatureInput[]
>;
function getCodexPrepareRuntimeSignature(
codex: NonNullable<NonNullable<CliProviderStatus['connection']>['codex']>
): Record<string, unknown> {
return {
preferredAuthMode: codex.preferredAuthMode,
effectiveAuthMode: codex.effectiveAuthMode,
managedAccountType: codex.managedAccount?.type ?? null,
requiresOpenaiAuth: codex.requiresOpenaiAuth ?? null,
launchAllowed: codex.launchAllowed,
launchReadinessState: codex.launchAllowed ? 'launchable' : codex.launchReadinessState,
};
}
function normalizeModelIds(modelIds: readonly string[] | null | undefined): string[] {
return Array.from(
new Set((modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
@ -100,22 +113,7 @@ export function buildProviderPrepareRuntimeStatusSignature(
apiKeyConfigured: provider.connection.apiKeyConfigured,
apiKeySource: provider.connection.apiKeySource ?? null,
codex: provider.connection.codex
? {
preferredAuthMode: provider.connection.codex.preferredAuthMode,
effectiveAuthMode: provider.connection.codex.effectiveAuthMode,
appServerState: provider.connection.codex.appServerState,
managedAccountType: provider.connection.codex.managedAccount?.type ?? null,
managedAccountEmail: provider.connection.codex.managedAccount?.email ?? null,
requiresOpenaiAuth: provider.connection.codex.requiresOpenaiAuth ?? null,
localAccountArtifactsPresent:
provider.connection.codex.localAccountArtifactsPresent ?? null,
localActiveChatgptAccountPresent:
provider.connection.codex.localActiveChatgptAccountPresent ?? null,
loginStatus: provider.connection.codex.login?.status ?? null,
launchAllowed: provider.connection.codex.launchAllowed,
launchIssueMessage: provider.connection.codex.launchIssueMessage ?? null,
launchReadinessState: provider.connection.codex.launchReadinessState,
}
? getCodexPrepareRuntimeSignature(provider.connection.codex)
: null,
}
: null,

View file

@ -59,7 +59,15 @@ describe('OpenCodeBridgeCommandClient', () => {
expect(runner.calls).toHaveLength(1);
expect(runner.calls[0]).toMatchObject({
binaryPath: '/usr/local/bin/agent-teams-controller',
args: ['runtime', 'opencode-command', '--json', '--input', expect.any(String)],
args: [
'runtime',
'opencode-command',
'--json',
'--input',
expect.any(String),
'--output',
expect.any(String),
],
cwd: '/tmp/project',
timeoutMs: 10_000,
env: expect.objectContaining({
@ -68,6 +76,7 @@ describe('OpenCodeBridgeCommandClient', () => {
});
const inputPath = runner.calls[0].args[4];
const outputPath = runner.calls[0].args[6];
expect(JSON.parse(await runner.readInputEnvelope(0))).toMatchObject({
schemaVersion: 1,
requestId: 'req-1',
@ -77,6 +86,33 @@ describe('OpenCodeBridgeCommandClient', () => {
body: { runId: 'run-1' },
});
await expect(fs.access(inputPath)).rejects.toThrow();
await expect(fs.access(outputPath)).rejects.toThrow();
});
it('reads bridge JSON from the output file when stdout is empty', async () => {
runner.nextResult = {
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false,
};
runner.nextOutputFileContents = `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`;
const client = createClient();
const result = await client.execute(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: '/tmp/project',
timeoutMs: 10_000,
}
);
expect(result).toMatchObject({
ok: true,
requestId: 'req-1',
command: 'opencode.launchTeam',
});
});
it('fails closed when stdout contains logs plus json', async () => {
@ -455,10 +491,17 @@ class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
exitCode: 0,
timedOut: false,
};
nextOutputFileContents: string | null = null;
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
this.calls.push(input);
this.inputEnvelopes.push(await fs.readFile(input.args[4], 'utf8'));
const outputFlagIndex = input.args.indexOf('--output');
const outputPath = outputFlagIndex >= 0 ? input.args[outputFlagIndex + 1] : undefined;
if (this.nextOutputFileContents !== null && outputPath) {
await fs.writeFile(outputPath, this.nextOutputFileContents, 'utf8');
this.nextOutputFileContents = null;
}
return this.nextResults.shift() ?? this.nextResult;
}

View file

@ -95,6 +95,8 @@ liveDescribe('provider launch stress live e2e', () => {
let previousNodeEnv: string | undefined;
let previousAnthropicApiKey: string | undefined;
let previousAnthropicAuthToken: string | undefined;
let previousRuntimeReadyTimeout: string | undefined;
let previousInboxPollerReadyTimeout: string | undefined;
let previousClaudeJsonConfig: string | null | undefined;
const activeScenarios: ActiveScenario[] = [];
@ -136,10 +138,16 @@ liveDescribe('provider launch stress live e2e', () => {
previousNodeEnv = process.env.NODE_ENV;
previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
previousAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
previousRuntimeReadyTimeout = process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS;
previousInboxPollerReadyTimeout = process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS;
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS =
process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000';
process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS =
process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000';
process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome);
process.env.HOME = usingAnthropicSubscriptionAuth() ? os.userInfo().homedir : tempHome;
process.env.USERPROFILE = usingAnthropicSubscriptionAuth() ? os.userInfo().homedir : tempHome;
@ -170,6 +178,8 @@ liveDescribe('provider launch stress live e2e', () => {
restoreEnv('NODE_ENV', previousNodeEnv);
restoreEnv('ANTHROPIC_API_KEY', previousAnthropicApiKey);
restoreEnv('ANTHROPIC_AUTH_TOKEN', previousAnthropicAuthToken);
restoreEnv('CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS', previousRuntimeReadyTimeout);
restoreEnv('CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS', previousInboxPollerReadyTimeout);
if (process.env.PROVIDER_LAUNCH_STRESS_KEEP_TEMP === '1') {
process.stderr.write(`[ProviderLaunchStress.live] preserved temp dir: ${tempDir}\n`);

View file

@ -600,6 +600,213 @@ describe('providerPrepareRequestSignature', () => {
expect(first).toBe(second);
});
it('ignores launchable Codex account telemetry churn', () => {
const providerIds = ['codex'] as const;
const first = buildProviderPrepareRuntimeStatusSignature(
providerIds,
providerStatusMap([
[
'codex',
{
providerId: 'codex',
supported: true,
authenticated: true,
authMethod: 'chatgpt',
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'chatgpt',
apiKeyConfigured: false,
apiKeySource: null,
codex: {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'plus',
},
requiresOpenaiAuth: false,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
},
},
},
],
])
);
const second = buildProviderPrepareRuntimeStatusSignature(
providerIds,
providerStatusMap([
[
'codex',
{
providerId: 'codex',
supported: true,
authenticated: true,
authMethod: 'chatgpt',
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'chatgpt',
apiKeyConfigured: false,
apiKeySource: null,
codex: {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
appServerState: 'degraded',
appServerStatusMessage: 'rate limits refresh failed',
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'plus',
},
requiresOpenaiAuth: false,
localAccountArtifactsPresent: false,
localActiveChatgptAccountPresent: true,
login: {
status: 'pending',
error: null,
startedAt: '2026-05-19T00:00:00.000Z',
},
rateLimits: {
limitId: 'codex',
limitName: null,
primary: {
usedPercent: 87,
windowDurationMins: 300,
resetsAt: 1_779_120_000_000,
},
secondary: null,
credits: null,
planType: 'plus',
},
launchAllowed: true,
launchIssueMessage: 'Ready with degraded account verification.',
launchReadinessState: 'warning_degraded_but_launchable',
},
},
},
],
])
);
expect(first).toBe(second);
});
it('changes the Codex runtime signature when launchability changes', () => {
const providerIds = ['codex'] as const;
const ready = buildProviderPrepareRuntimeStatusSignature(
providerIds,
providerStatusMap([
[
'codex',
{
providerId: 'codex',
supported: true,
authenticated: true,
authMethod: 'chatgpt',
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'chatgpt',
apiKeyConfigured: false,
apiKeySource: null,
codex: {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'plus',
},
requiresOpenaiAuth: false,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
},
},
},
],
])
);
const missingAuth = buildProviderPrepareRuntimeStatusSignature(
providerIds,
providerStatusMap([
[
'codex',
{
providerId: 'codex',
supported: true,
authenticated: false,
authMethod: null,
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
connection: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
configuredAuthMode: 'chatgpt',
apiKeyConfigured: false,
apiKeySource: null,
codex: {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: false,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: false,
launchIssueMessage: 'Connect a ChatGPT account.',
launchReadinessState: 'missing_auth',
},
},
},
],
])
);
expect(ready).not.toBe(missingAuth);
});
it('ignores volatile member draft ids in provider prepare signatures', () => {
const first = buildProviderPrepareMembersSignature([
{

View file

@ -17,6 +17,7 @@ interface StopChildOptions {
interface OpenCodeLivePreflightTestHooks {
__opencodeLivePreflightTestHooks: {
isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean;
stopChild(child: FakeChild, options?: StopChildOptions): Promise<void>;
taskkillProcessTree(pid: number): Promise<void>;
};
@ -38,6 +39,14 @@ describe('opencode live preflight cleanup', () => {
}
});
it('accepts an HTTP 2xx OpenCode health response without requiring a JSON body', async () => {
const { isHealthyOpenCodeHostResponse } = (await loadTestHooks())
.__opencodeLivePreflightTestHooks;
expect(isHealthyOpenCodeHostResponse({ ok: true })).toBe(true);
expect(isHealthyOpenCodeHostResponse({ ok: false })).toBe(false);
});
it('waits for child close after Windows process-tree cleanup', async () => {
const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks;
const child = new FakeChild({ pid: 1234 });