agent-ecosystem/test/main/services/team/OpenCodeMixedRecovery.live.test.ts

498 lines
17 KiB
TypeScript

import { constants as fsConstants, promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import {
createOpenCodeBridgeCommandLeaseStore,
createOpenCodeBridgeCommandLedgerStore,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
import {
createOpenCodeBridgeClientIdentity,
OpenCodeBridgeCommandHandshakePort,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import {
readOpenCodeRuntimeLaneIndex,
upsertOpenCodeRuntimeLaneIndexEntry,
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import type {
TeamRuntimeLaunchInput,
TeamRuntimeStopInput,
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
const liveDescribe =
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MIXED_RECOVERY === '1'
? describe
: describe.skip;
const liveMultiLaneIt = process.env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? it : it.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode mixed recovery live e2e', () => {
let tempDir: string;
let tempClaudeRoot: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-mixed-recovery-e2e-'));
tempClaudeRoot = path.join(tempDir, '.claude');
await fs.mkdir(tempClaudeRoot, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
});
afterEach(async () => {
setClaudeBasePathOverride(null);
await fs.rm(tempDir, { recursive: true, force: true });
});
it('recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', async () => {
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
const orchestratorCli =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
await assertExecutable(orchestratorCli);
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
const bridgeEnv = {
...createStableBridgeEnv(),
PATH: withBunOnPath(process.env.PATH ?? ''),
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'),
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
};
const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath: orchestratorCli,
tempDirectory: path.join(tempDir, 'bridge-input'),
env: bridgeEnv,
});
const stateChangingCommands = createStateChangingCommands({
bridge: bridgeClient,
controlDir: path.join(tempDir, 'control'),
});
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
timeoutMs: 180_000,
launchTimeoutMs: 180_000,
reconcileTimeoutMs: 90_000,
stopTimeoutMs: 90_000,
});
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const teamName = `mixed-opencode-recovery-${Date.now()}`;
const launchedLanes: TeamRuntimeLaunchInput[] = [];
await writeMixedRecoveryFixtures({
teamName,
projectPath: PROJECT_PATH,
secondaryMembers: ['bob'],
});
try {
const launchInput = createSecondaryLaneLaunchInput({
teamName,
laneId: 'secondary:opencode:bob',
memberName: 'bob',
selectedModel,
});
launchedLanes.push(launchInput);
const rawLaunchResult = await adapter.launch(launchInput);
const launchResult = await commitMixedOpenCodeLaunchResult({
service: svc,
teamName,
laneId: launchInput.laneId ?? 'secondary:opencode:bob',
memberName: 'bob',
result: rawLaunchResult,
});
expectCleanOpenCodeLaunch(launchResult);
expect(launchResult.members.bob).toMatchObject({
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
});
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName,
laneId: launchInput.laneId ?? 'secondary:opencode:bob',
state: 'active',
});
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob']));
expect(result.statuses.bob).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(result.statuses.bob.error).toBeUndefined();
await expect(
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
).resolves.toMatchObject({
lanes: {
[launchInput.laneId ?? 'secondary:opencode:bob']: {
state: 'active',
},
},
});
} finally {
for (const launchInput of launchedLanes) {
await adapter
.stop({
runId: launchInput.runId,
laneId: launchInput.laneId,
teamName,
cwd: PROJECT_PATH,
providerId: 'opencode',
reason: 'cleanup',
previousLaunchState: null,
force: true,
} satisfies TeamRuntimeStopInput)
.catch(() => undefined);
}
}
}, 240_000);
liveMultiLaneIt(
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
async () => {
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
const orchestratorCli =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
await assertExecutable(orchestratorCli);
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
const bridgeEnv = {
...createStableBridgeEnv(),
PATH: withBunOnPath(process.env.PATH ?? ''),
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'),
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
};
const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath: orchestratorCli,
tempDirectory: path.join(tempDir, 'bridge-input-multi'),
env: bridgeEnv,
});
const stateChangingCommands = createStateChangingCommands({
bridge: bridgeClient,
controlDir: path.join(tempDir, 'control-multi'),
});
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
timeoutMs: 180_000,
launchTimeoutMs: 180_000,
reconcileTimeoutMs: 90_000,
stopTimeoutMs: 90_000,
});
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const teamName = `mixed-opencode-recovery-multi-${Date.now()}`;
const sideMembers = ['bob', 'jack', 'tom'] as const;
const launchedLanes: TeamRuntimeLaunchInput[] = [];
await writeMixedRecoveryFixtures({
teamName,
projectPath: PROJECT_PATH,
secondaryMembers: [...sideMembers],
});
try {
for (const memberName of sideMembers) {
const launchInput = createSecondaryLaneLaunchInput({
teamName,
laneId: `secondary:opencode:${memberName}`,
memberName,
selectedModel,
});
launchedLanes.push(launchInput);
const rawLaunchResult = await adapter.launch(launchInput);
const launchResult = await commitMixedOpenCodeLaunchResult({
service: svc,
teamName,
laneId: launchInput.laneId ?? `secondary:opencode:${memberName}`,
memberName,
result: rawLaunchResult,
});
expectCleanOpenCodeLaunch(launchResult);
expect(launchResult.members[memberName]).toMatchObject({
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
});
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName,
laneId: launchInput.laneId ?? `secondary:opencode:${memberName}`,
state: 'active',
});
}
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.expectedMembers).toEqual(
expect.arrayContaining(['alice', 'bob', 'jack', 'tom'])
);
for (const memberName of sideMembers) {
expect(result.statuses[memberName]).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(result.statuses[memberName]?.error).toBeUndefined();
}
await expect(
readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
).resolves.toMatchObject({
lanes: Object.fromEntries(
sideMembers.map((memberName) => [
`secondary:opencode:${memberName}`,
{ state: 'active' },
])
),
});
} finally {
for (const launchInput of launchedLanes) {
await adapter
.stop({
runId: launchInput.runId,
laneId: launchInput.laneId,
teamName,
cwd: PROJECT_PATH,
providerId: 'opencode',
reason: 'cleanup',
previousLaunchState: null,
force: true,
} satisfies TeamRuntimeStopInput)
.catch(() => undefined);
}
}
},
420_000
);
});
function createSecondaryLaneLaunchInput(input: {
teamName: string;
laneId: string;
memberName: string;
selectedModel: string;
}): TeamRuntimeLaunchInput {
return {
runId: `mixed-opencode-recovery-${Date.now()}`,
laneId: input.laneId,
teamName: input.teamName,
cwd: PROJECT_PATH,
prompt: 'Mixed OpenCode recovery live e2e',
providerId: 'opencode',
model: input.selectedModel,
skipPermissions: true,
expectedMembers: [
{
name: input.memberName,
role: 'Developer',
providerId: 'opencode',
model: input.selectedModel,
cwd: PROJECT_PATH,
},
],
previousLaunchState: null,
};
}
function expectCleanOpenCodeLaunch(
launchResult: Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>
): void {
if (launchResult.teamLaunchState !== 'clean_success') {
throw new Error(
`Expected OpenCode launch to be clean_success, received ${launchResult.teamLaunchState}:\n${JSON.stringify(
launchResult,
null,
2
)}`
);
}
expect(launchResult.teamLaunchState).toBe('clean_success');
}
async function commitMixedOpenCodeLaunchResult(input: {
service: TeamProvisioningService;
teamName: string;
laneId: string;
memberName: string;
result: Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>;
}): Promise<Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>> {
const service = input.service as unknown as {
guardCommittedOpenCodeSecondaryLaneEvidence(args: {
teamName: string;
laneId: string;
memberName: string;
result: Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>;
}): Promise<Awaited<ReturnType<OpenCodeTeamRuntimeAdapter['launch']>>>;
};
return service.guardCommittedOpenCodeSecondaryLaneEvidence({
teamName: input.teamName,
laneId: input.laneId,
memberName: input.memberName,
result: input.result,
});
}
async function writeMixedRecoveryFixtures(input: {
teamName: string;
projectPath: string;
secondaryMembers: string[];
}): Promise<void> {
const teamDir = path.join(getTeamsBasePath(), input.teamName);
await fs.mkdir(teamDir, { recursive: true });
await new TeamMetaStore().writeMeta(input.teamName, {
cwd: input.projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
createdAt: Date.now(),
});
await new TeamMembersMetaStore().writeMembers(
input.teamName,
[
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
...input.secondaryMembers.map((memberName) => ({
name: memberName,
role: 'Developer',
providerId: 'opencode' as const,
model: process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL,
})),
],
{
providerBackendId: 'codex-native',
}
);
await fs.writeFile(
path.join(teamDir, 'config.json'),
`${JSON.stringify(
{
name: input.teamName,
projectPath: input.projectPath,
leadSessionId: 'lead-session',
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'alice' }],
},
null,
2
)}\n`,
'utf8'
);
await fs.writeFile(
getTeamBootstrapStatePath(input.teamName),
`${JSON.stringify(
{
version: 1,
teamName: input.teamName,
updatedAt: new Date().toISOString(),
phase: 'completed',
members: [
{
name: 'alice',
status: 'registered',
lastAttemptAt: Date.now(),
lastObservedAt: Date.now(),
},
],
terminal: {
status: 'completed',
},
},
null,
2
)}\n`,
'utf8'
);
}
function createStateChangingCommands(input: {
bridge: OpenCodeBridgeCommandExecutor;
controlDir: string;
}): OpenCodeStateChangingBridgeCommandService {
const clientIdentity = createOpenCodeBridgeClientIdentity({
appVersion: '1.3.0-e2e',
gitSha: null,
buildId: 'opencode-mixed-recovery-e2e',
});
return new OpenCodeStateChangingBridgeCommandService({
expectedClientIdentity: clientIdentity,
handshakePort: new OpenCodeBridgeCommandHandshakePort({
bridge: input.bridge,
clientIdentity,
}),
leaseStore: createOpenCodeBridgeCommandLeaseStore({
filePath: path.join(input.controlDir, 'leases.json'),
}),
ledger: createOpenCodeBridgeCommandLedgerStore({
filePath: path.join(input.controlDir, 'ledger.json'),
}),
bridge: input.bridge,
manifestReader: new StaticManifestReader(),
});
}
class StaticManifestReader implements RuntimeStoreManifestReader {
async read(): Promise<RuntimeStoreManifestEvidence> {
return {
highWatermark: 0,
activeRunId: null,
capabilitySnapshotId: null,
};
}
}
async function assertExecutable(filePath: string): Promise<void> {
await fs.access(filePath, fsConstants.X_OK);
}
function withBunOnPath(pathValue: string): string {
const bunDir = '/Users/belief/.bun/bin';
return pathValue.split(path.delimiter).includes(bunDir)
? pathValue
: `${bunDir}${path.delimiter}${pathValue}`;
}
function createStableBridgeEnv(): NodeJS.ProcessEnv {
const realHome = os.userInfo().homedir;
const env = applyOpenCodeAutoUpdatePolicy({ ...process.env });
return {
...env,
HOME: realHome,
USERPROFILE: realHome,
};
}