test(team): add opencode mixed recovery smoke

This commit is contained in:
777genius 2026-04-23 13:25:38 +03:00
parent fe830da37d
commit fa8bbcbb38
6 changed files with 1568 additions and 29 deletions

View file

@ -22,6 +22,8 @@
"dev:web": "node ./scripts/dev-web.mjs",
"dev:kill": "node bin/kill-dev.js",
"opencode:prove-production": "node ./scripts/prove-opencode-production.mjs",
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
"opencode:prove-team-provisioning": "OPENCODE_E2E=1 OPENCODE_E2E_TEAM_PROVISIONING=1 pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/OpenCodeTeamProvisioning.live.test.ts",
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",

View file

@ -0,0 +1,57 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
const env = {
...process.env,
OPENCODE_E2E: '1',
OPENCODE_E2E_MIXED_RECOVERY: '1',
OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
}
console.log('Running OpenCode mixed recovery live smoke');
console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
const result = spawnSync(
'pnpm',
[
'exec',
'vitest',
'run',
'--maxWorkers',
'1',
'--minWorkers',
'1',
'test/main/services/team/OpenCodeMixedRecovery.live.test.ts',
],
{
cwd: repoRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
}
);
if (result.error) {
console.error(`Failed to run OpenCode mixed recovery smoke: ${result.error.message}`);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -1924,6 +1924,39 @@ function extractBootstrapFailureReason(text: string): string | null {
return trimmed.slice(0, 280);
}
function isBootstrapTranscriptSuccessText(
text: string,
teamName: string,
memberName: string
): boolean {
const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase();
if (!normalizedText) {
return false;
}
const normalizedTeamName = teamName.trim().toLowerCase();
const normalizedMemberName = memberName.trim().toLowerCase();
if (!normalizedTeamName || !normalizedMemberName) {
return false;
}
if (
normalizedText.startsWith(
`member briefing for ${normalizedMemberName} on team "${normalizedTeamName}" (${normalizedTeamName}).`
) ||
normalizedText.startsWith(
`member briefing for ${normalizedMemberName} on team '${normalizedTeamName}' (${normalizedTeamName}).`
)
) {
return true;
}
return (
normalizedText.includes(`bootstrap выполнен для \`${normalizedMemberName}\``) &&
normalizedText.includes(`команде \`${normalizedTeamName}\``)
);
}
function extractTranscriptTextContent(value: unknown): string[] {
if (typeof value === 'string') {
const trimmed = value.trim();
@ -6134,6 +6167,58 @@ export class TeamProvisioningService {
}
}
private confirmMemberSpawnStatusFromTranscript(
run: ProvisioningRun,
memberName: string,
observedAt: string
): void {
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
const updatedAt = nowIso();
const next: MemberSpawnStatusEntry = {
...prev,
status: 'online',
updatedAt,
agentToolAccepted: true,
runtimeAlive: prev.runtimeAlive === true,
bootstrapConfirmed: true,
hardFailure: false,
error: undefined,
hardFailureReason: undefined,
livenessSource: prev.livenessSource ?? 'process',
firstSpawnAcceptedAt: prev.firstSpawnAcceptedAt ?? observedAt,
lastHeartbeatAt: isMemberSpawnHeartbeatTimestampNewer(prev.lastHeartbeatAt, observedAt)
? observedAt
: prev.lastHeartbeatAt,
};
next.launchState = deriveMemberLaunchState(next);
if (
prev.status === next.status &&
prev.launchState === next.launchState &&
prev.error === next.error &&
prev.hardFailureReason === next.hardFailureReason &&
prev.livenessSource === next.livenessSource &&
prev.agentToolAccepted === next.agentToolAccepted &&
prev.runtimeAlive === next.runtimeAlive &&
prev.bootstrapConfirmed === next.bootstrapConfirmed &&
prev.hardFailure === next.hardFailure &&
prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt &&
prev.lastHeartbeatAt === next.lastHeartbeatAt
) {
return;
}
run.memberSpawnStatuses.set(memberName, next);
run.pendingMemberRestarts?.delete(memberName);
this.syncMemberLaunchGraceCheck(run, memberName, next);
this.appendMemberBootstrapDiagnostic(run, memberName, 'bootstrap confirmed via transcript');
if (!this.isCurrentTrackedRun(run)) return;
this.emitMemberSpawnChange(run, memberName);
if (run.isLaunch) {
void this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active');
}
}
/**
* Get current member spawn statuses for a team.
* Returns a map of memberName MemberSpawnStatusEntry.
@ -6349,6 +6434,11 @@ export class TeamProvisioningService {
launchMember?.model?.trim() ??
member.model?.trim() ??
undefined;
const launchSnapshotAlive =
this.isTeamAlive(teamName) &&
(launchMember?.runtimeAlive === true ||
launchMember?.bootstrapConfirmed === true ||
launchMember?.launchState === 'confirmed_alive');
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
try {
@ -6364,7 +6454,7 @@ export class TeamProvisioningService {
snapshotMembers[memberName] = {
memberName,
alive: liveRuntimeMember?.alive ?? launchMember?.runtimeAlive ?? false,
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive,
restartable,
...(backendType ? { backendType } : {}),
...(launchMember?.providerId ? { providerId: launchMember.providerId } : {}),
@ -6892,6 +6982,7 @@ export class TeamProvisioningService {
}
run.lastMemberSpawnAuditAt = now;
await this.auditMemberSpawnStatuses(run);
await this.reconcileBootstrapTranscriptSuccesses(run);
}
private async reconcileBootstrapTranscriptFailures(run: ProvisioningRun): Promise<void> {
@ -6920,6 +7011,32 @@ export class TeamProvisioningService {
}
}
private async reconcileBootstrapTranscriptSuccesses(run: ProvisioningRun): Promise<void> {
for (const memberName of run.expectedMembers ?? []) {
const current = run.memberSpawnStatuses.get(memberName);
if (
!current ||
current.launchState === 'failed_to_start' ||
current.launchState === 'confirmed_alive' ||
current.bootstrapConfirmed === true ||
current.agentToolAccepted !== true
) {
continue;
}
const acceptedAtMs =
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
run.teamName,
memberName,
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
);
if (transcriptOutcome?.kind !== 'success') {
continue;
}
this.confirmMemberSpawnStatusFromTranscript(run, memberName, transcriptOutcome.observedAt);
}
}
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
@ -12147,6 +12264,8 @@ export class TeamProvisioningService {
primaryStatuses: this.buildRuntimeSpawnStatusRecord(run),
secondaryMembers: mixedSecondaryLanes.map((secondaryLane) => {
const evidenceEntry = secondaryLane.result?.members[secondaryLane.member.name];
const finishedWithoutRuntimeEvidence =
secondaryLane.state === 'finished' && !secondaryLane.result;
return {
laneId: secondaryLane.laneId,
member: secondaryLane.member,
@ -12173,7 +12292,21 @@ export class TeamProvisioningService {
pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds,
diagnostics: evidenceEntry.diagnostics,
}
: null,
: finishedWithoutRuntimeEvidence
? {
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
diagnostics:
secondaryLane.diagnostics.length > 0
? [...secondaryLane.diagnostics]
: [
'OpenCode secondary lane finished without runtime evidence. Waiting for runtime reconciliation.',
],
}
: null,
pendingReason:
secondaryLane.result || secondaryLane.state === 'finished'
? undefined
@ -12498,6 +12631,7 @@ export class TeamProvisioningService {
if (activeMembers.length === 0) {
return null;
}
const projectPath = this.readPersistedTeamProjectPath(teamName);
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(
() => ({
@ -12541,6 +12675,7 @@ export class TeamProvisioningService {
bootstrapConfirmed?: boolean;
hardFailure?: boolean;
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
diagnostics?: string[];
};
pendingReason?: string;
@ -12566,6 +12701,32 @@ export class TeamProvisioningService {
let laneEntry = laneIndex.lanes[laneIdentity.laneId];
if (laneEntry?.state === 'active') {
const runtimeEvidence = await this.tryRecoverActiveOpenCodeSecondaryLaneFromRuntime({
teamName,
laneId: laneIdentity.laneId,
member,
projectPath,
previousLaunchState: persistedSnapshot ?? bootstrapSnapshot,
});
if (runtimeEvidence) {
recoveredAny = true;
secondaryMembers.push({
laneId: laneIdentity.laneId,
member,
leadDefaults,
evidence: {
launchState: runtimeEvidence.launchState,
agentToolAccepted: runtimeEvidence.agentToolAccepted,
runtimeAlive: runtimeEvidence.runtimeAlive,
bootstrapConfirmed: runtimeEvidence.bootstrapConfirmed,
hardFailure: runtimeEvidence.hardFailure,
hardFailureReason: runtimeEvidence.hardFailureReason,
pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds,
diagnostics: runtimeEvidence.diagnostics,
},
});
continue;
}
const recovery = await recoverStaleOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName,
@ -12640,6 +12801,50 @@ export class TeamProvisioningService {
return recoveredSnapshot;
}
private async tryRecoverActiveOpenCodeSecondaryLaneFromRuntime(params: {
teamName: string;
laneId: string;
member: TeamMember;
projectPath: string | null;
previousLaunchState: PersistedTeamLaunchSnapshot | null;
}): Promise<TeamRuntimeMemberLaunchEvidence | null> {
const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter || !params.projectPath) {
return null;
}
try {
const reconcileResult = await adapter.reconcile({
runId: randomUUID(),
laneId: params.laneId,
teamName: params.teamName,
providerId: 'opencode',
expectedMembers: [
{
name: params.member.name,
role: params.member.role,
workflow: params.member.workflow,
isolation: params.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: 'opencode',
model: params.member.model,
effort: params.member.effort,
cwd: params.projectPath,
},
],
previousLaunchState: params.previousLaunchState,
reason: 'startup_recovery',
});
return reconcileResult.members[params.member.name] ?? null;
} catch (error) {
logger.warn(
`[${params.teamName}] Failed to recover stale OpenCode lane ${params.laneId} from runtime bridge: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
}
private async reconcilePersistedLaunchState(teamName: string): Promise<{
snapshot: ReturnType<typeof createPersistedLaunchSnapshot> | null;
statuses: Record<string, MemberSpawnStatusEntry>;
@ -12802,14 +13007,19 @@ export class TeamProvisioningService {
current.hardFailureReason = undefined;
}
if (!current.bootstrapConfirmed && !current.hardFailure) {
const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason(
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
teamName,
expected,
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
);
if (transcriptFailureReason) {
if (transcriptOutcome?.kind === 'success') {
current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? transcriptOutcome.observedAt;
current.hardFailure = false;
current.hardFailureReason = undefined;
} else if (transcriptOutcome?.kind === 'failure') {
current.hardFailure = true;
current.hardFailureReason = transcriptFailureReason;
current.hardFailureReason = transcriptOutcome.reason;
current.sources.hardFailureSignal = true;
}
}
@ -12864,6 +13074,26 @@ export class TeamProvisioningService {
memberName: string,
sinceMs: number | null
): Promise<string | null> {
const outcome = await this.findBootstrapTranscriptOutcome(teamName, memberName, sinceMs);
return outcome?.kind === 'failure' ? outcome.reason : null;
}
private async findBootstrapTranscriptOutcome(
teamName: string,
memberName: string,
sinceMs: number | null
): Promise<
| {
kind: 'success';
observedAt: string;
}
| {
kind: 'failure';
observedAt: string;
reason: string;
}
| null
> {
let summaries: Awaited<ReturnType<TeamMemberLogsFinder['findMemberLogs']>>;
try {
summaries = await this.memberLogsFinder.findMemberLogs(teamName, memberName, sinceMs);
@ -12873,26 +13103,39 @@ export class TeamProvisioningService {
for (const summary of summaries) {
if (!summary.filePath) continue;
const reason = await this.readRecentBootstrapFailureReason(
const outcome = await this.readRecentBootstrapTranscriptOutcome(
summary.filePath,
sinceMs,
memberName
memberName,
teamName
);
if (reason) {
return reason;
if (outcome) {
return outcome;
}
}
return this.findBootstrapFailureReasonInProjectRoot(teamName, memberName, sinceMs);
return this.findBootstrapTranscriptOutcomeInProjectRoot(teamName, memberName, sinceMs);
}
private async readRecentBootstrapFailureReason(
private async readRecentBootstrapTranscriptOutcome(
filePath: string,
sinceMs: number | null,
memberName?: string
): Promise<string | null> {
memberName: string,
teamName: string
): Promise<
| {
kind: 'success';
observedAt: string;
}
| {
kind: 'failure';
observedAt: string;
reason: string;
}
| null
> {
let handle: fs.promises.FileHandle | null = null;
const normalizedMemberName = memberName?.trim().toLowerCase() || null;
const normalizedMemberName = memberName.trim().toLowerCase();
try {
handle = await fs.promises.open(filePath, 'r');
const stat = await handle.stat();
@ -12923,20 +13166,25 @@ export class TeamProvisioningService {
if (sinceMs != null && Number.isFinite(timestampMs) && timestampMs < sinceMs) {
continue;
}
if (normalizedMemberName) {
const parsedAgentName =
typeof (parsed as { agentName?: unknown }).agentName === 'string'
? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null
: null;
if (parsedAgentName && parsedAgentName !== normalizedMemberName) {
continue;
}
const parsedAgentName =
typeof (parsed as { agentName?: unknown }).agentName === 'string'
? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null
: null;
if (parsedAgentName && parsedAgentName !== normalizedMemberName) {
continue;
}
const text = extractTranscriptMessageText(parsed);
if (!text) continue;
const observedAt =
typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0
? parsed.timestamp.trim()
: new Date().toISOString();
const reason = extractBootstrapFailureReason(text);
if (reason) {
return reason;
return { kind: 'failure', observedAt, reason };
}
if (isBootstrapTranscriptSuccessText(text, teamName, memberName)) {
return { kind: 'success', observedAt };
}
}
} catch {
@ -12948,11 +13196,22 @@ export class TeamProvisioningService {
return null;
}
private async findBootstrapFailureReasonInProjectRoot(
private async findBootstrapTranscriptOutcomeInProjectRoot(
teamName: string,
memberName: string,
sinceMs: number | null
): Promise<string | null> {
): Promise<
| {
kind: 'success';
observedAt: string;
}
| {
kind: 'failure';
observedAt: string;
reason: string;
}
| null
> {
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>>;
try {
config = await this.configReader.getConfig(teamName);
@ -12979,13 +13238,14 @@ export class TeamProvisioningService {
if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) {
continue;
}
const reason = await this.readRecentBootstrapFailureReason(
const outcome = await this.readRecentBootstrapTranscriptOutcome(
path.join(projectDir, entry.name),
sinceMs,
memberName
memberName,
teamName
);
if (reason) {
return reason;
if (outcome) {
return outcome;
}
}

View file

@ -0,0 +1,453 @@
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 { 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 {
getTeamBootstrapStatePath,
} from '../../../../src/main/services/team/TeamBootstrapStateReader';
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 { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import {
readOpenCodeRuntimeLaneIndex,
upsertOpenCodeRuntimeLaneIndexEntry,
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
import {
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import type {
TeamRuntimeLaunchInput,
TeamRuntimeStopInput,
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
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';
const liveDescribe =
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MIXED_RECOVERY === '1'
? describe
: describe.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';
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 ?? ''),
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
};
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, {
launchMode: 'dogfood',
});
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 launchResult = await adapter.launch(launchInput);
expect(launchResult.teamLaunchState).toBe('clean_success');
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
);
it(
'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 ?? ''),
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
};
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, {
launchMode: 'dogfood',
});
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 launchResult = await adapter.launch(launchInput);
expect(launchResult.teamLaunchState).toBe('clean_success');
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,
};
}
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,
};
}

View file

@ -0,0 +1,251 @@
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 { 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 { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
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 { TeamProvisioningProgress } from '../../../../src/shared/types';
const liveDescribe =
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_TEAM_PROVISIONING === '1'
? describe
: describe.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';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode team provisioning live e2e', () => {
let tempDir: string;
let tempClaudeRoot: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-team-provisioning-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(
'creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter',
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 ?? ''),
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
};
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, {
launchMode: 'dogfood',
});
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const teamName = `opencode-team-provisioning-${Date.now()}`;
const progressEvents: TeamProvisioningProgress[] = [];
try {
const { runId } = await svc.createTeam(
{
teamName,
cwd: PROJECT_PATH,
providerId: 'opencode',
model: selectedModel,
skipPermissions: true,
members: [
{
name: 'alice',
role: 'Developer',
providerId: 'opencode',
model: selectedModel,
},
{
name: 'bob',
role: 'Reviewer',
providerId: 'opencode',
model: selectedModel,
},
],
},
(progress) => {
progressEvents.push(progress);
}
);
expect(runId).toBeTruthy();
const progressDump = progressEvents
.map((progress) =>
[
progress.state,
progress.message,
progress.messageSeverity,
progress.error,
progress.cliLogsTail,
]
.filter(Boolean)
.join(' | ')
)
.join('\n');
expect(
progressEvents.some((progress) =>
progress.message.includes('OpenCode team launch is ready')
),
progressDump
).toBe(true);
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(runtimeSnapshot.members.alice).toMatchObject({
alive: true,
runtimeModel: selectedModel,
});
expect(runtimeSnapshot.members.bob).toMatchObject({
alive: true,
runtimeModel: selectedModel,
});
await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
{
lanes: {
primary: {
state: 'active',
},
},
}
);
svc.stopTeam(teamName);
await waitUntil(async () => {
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
return Object.keys(laneIndex.lanes).length === 0;
}, 90_000);
} finally {
svc.stopTeam(teamName);
}
},
300_000
);
});
function createStateChangingCommands(input: {
bridge: OpenCodeBridgeCommandExecutor;
controlDir: string;
}): OpenCodeStateChangingBridgeCommandService {
const clientIdentity = createOpenCodeBridgeClientIdentity({
appVersion: '1.3.0-e2e',
gitSha: null,
buildId: 'opencode-team-provisioning-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 waitUntil(
predicate: () => Promise<boolean>,
timeoutMs: number,
pollMs = 500
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
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,
};
}

View file

@ -805,6 +805,40 @@ describe('TeamProvisioningService', () => {
expect(snapshot.members.alice).toBeUndefined();
});
it('keeps pure OpenCode launch members alive from confirmed launch snapshot while runtime adapter is tracked', async () => {
const teamName = 'pure-opencode-runtime-team';
const projectPath = '/Users/test/project';
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
writeLaunchState(teamName, 'lead-session', {
alice: {
providerId: 'opencode',
model: 'opencode/big-pickle',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
hardFailureReason: undefined,
},
});
const svc = new TeamProvisioningService();
(svc as any).runtimeAdapterRunByTeam.set(teamName, {
runId: 'opencode-runtime-run',
providerId: 'opencode',
cwd: projectPath,
});
(svc as any).aliveRunByTeam.set(teamName, 'opencode-runtime-run');
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.alice).toMatchObject({
alive: true,
providerId: 'opencode',
runtimeModel: 'opencode/big-pickle',
});
});
it('excludes removed meta members from live runtime metadata resolution', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
@ -4842,6 +4876,97 @@ describe('TeamProvisioningService', () => {
expect(result.teamLaunchState).toBe('partial_failure');
});
it('marks persisted bootstrap as confirmed when member transcript shows successful member_briefing', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-bootstrap-transcript-success';
const leadSessionId = 'lead-session';
const memberSessionId = 'alice-session';
const projectPath = '/Users/test/proj';
const projectId = '-Users-test-proj';
const acceptedAt = new Date(Date.now() - 5_000).toISOString();
const successAt = new Date(Date.now() - 4_000).toISOString();
writeLaunchConfig(teamName, projectPath, leadSessionId, ['alice', 'bob']);
writeLaunchState(teamName, leadSessionId, {
alice: {
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
firstSpawnAcceptedAt: acceptedAt,
},
bob: {
launchState: 'starting',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
},
});
const projectRoot = path.join(tempProjectsBase, projectId);
fs.mkdirSync(projectRoot, { recursive: true });
fs.writeFileSync(
path.join(projectRoot, `${leadSessionId}.jsonl`),
`${JSON.stringify({
timestamp: new Date(Date.now() - 10_000).toISOString(),
teamName,
type: 'user',
message: { role: 'user', content: 'Lead bootstrap context' },
})}\n`,
'utf8'
);
fs.writeFileSync(
path.join(projectRoot, `${memberSessionId}.jsonl`),
[
JSON.stringify({
timestamp: acceptedAt,
teamName,
agentName: 'alice',
type: 'user',
message: {
role: 'user',
content: `You are bootstrapping into team "${teamName}" as member "alice".`,
},
}),
JSON.stringify({
timestamp: successAt,
teamName,
agentName: 'alice',
type: 'user',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'item_1',
content: `Member briefing for alice on team "${teamName}" (${teamName}).\nTask briefing for alice:\nNo actionable tasks.`,
is_error: false,
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['alice']));
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.alice).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: true,
});
expect(result.statuses.alice?.error).toBeUndefined();
});
it('marks an online teammate bootstrap as failed when transcript shows model unavailability', async () => {
allowConsoleLogs();
const teamName = 'zz-live-bootstrap-model-unavailable';
@ -4937,6 +5062,184 @@ describe('TeamProvisioningService', () => {
expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available');
});
it('marks a live teammate bootstrap as confirmed when transcript shows successful member_briefing', async () => {
allowConsoleLogs();
const teamName = 'zz-live-bootstrap-transcript-success';
const leadSessionId = 'lead-session';
const memberSessionId = 'alice-session';
const projectPath = '/Users/test/proj';
const projectId = '-Users-test-proj';
const acceptedAt = new Date(Date.now() - 5_000).toISOString();
const successAt = new Date(Date.now() - 4_000).toISOString();
writeLaunchConfig(teamName, projectPath, leadSessionId, ['alice']);
const projectRoot = path.join(tempProjectsBase, projectId);
fs.mkdirSync(projectRoot, { recursive: true });
fs.writeFileSync(
path.join(projectRoot, `${memberSessionId}.jsonl`),
[
JSON.stringify({
timestamp: acceptedAt,
teamName,
agentName: 'alice',
type: 'user',
message: {
role: 'user',
content: `You are bootstrapping into team "${teamName}" as member "alice".`,
},
}),
JSON.stringify({
timestamp: successAt,
teamName,
agentName: 'alice',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'text',
text: `Bootstrap выполнен для \`alice\` в команде \`${teamName}\`.`,
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const svc = new TeamProvisioningService();
const run = {
runId: 'run-live-success-1',
teamName,
startedAt: new Date(Date.now() - 60_000).toISOString(),
request: {
members: [],
},
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
{
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
error: undefined,
updatedAt: acceptedAt,
runtimeAlive: true,
livenessSource: 'process',
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
firstSpawnAcceptedAt: acceptedAt,
lastHeartbeatAt: undefined,
},
],
]),
provisioningOutputParts: [],
activeToolCalls: new Map(),
isLaunch: false,
} as any;
await (svc as any).reconcileBootstrapTranscriptSuccesses(run);
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
});
expect(run.provisioningOutputParts.join('\n')).toContain('bootstrap confirmed via transcript');
});
it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => {
allowConsoleLogs();
const teamName = 'zz-live-bootstrap-transcript-success-without-runtime';
const leadSessionId = 'lead-session';
const memberSessionId = 'atlas-session';
const projectPath = '/Users/test/proj';
const projectId = '-Users-test-proj';
const acceptedAt = new Date(Date.now() - 5_000).toISOString();
const successAt = new Date(Date.now() - 4_000).toISOString();
writeLaunchConfig(teamName, projectPath, leadSessionId, ['atlas']);
const projectRoot = path.join(tempProjectsBase, projectId);
fs.mkdirSync(projectRoot, { recursive: true });
fs.writeFileSync(
path.join(projectRoot, `${memberSessionId}.jsonl`),
[
JSON.stringify({
timestamp: acceptedAt,
teamName,
agentName: 'atlas',
type: 'user',
message: {
role: 'user',
content: `You are bootstrapping into team "${teamName}" as member "atlas".`,
},
}),
JSON.stringify({
timestamp: successAt,
teamName,
agentName: 'atlas',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'text',
text: `Bootstrap выполнен для \`atlas\` в команде \`${teamName}\`.`,
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const svc = new TeamProvisioningService();
const run = {
runId: 'run-live-success-2',
teamName,
startedAt: new Date(Date.now() - 60_000).toISOString(),
request: {
members: [],
},
expectedMembers: ['atlas'],
memberSpawnStatuses: new Map([
[
'atlas',
{
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
error: undefined,
updatedAt: acceptedAt,
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
firstSpawnAcceptedAt: acceptedAt,
lastHeartbeatAt: undefined,
},
],
]),
provisioningOutputParts: [],
activeToolCalls: new Map(),
isLaunch: false,
} as any;
await (svc as any).reconcileBootstrapTranscriptSuccesses(run);
expect(run.memberSpawnStatuses.get('atlas')).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: false,
bootstrapConfirmed: true,
});
expect(run.provisioningOutputParts.join('\n')).toContain('bootstrap confirmed via transcript');
});
it('marks a persisted online teammate bootstrap as failed when transcript shows model unavailability', async () => {
allowConsoleLogs();
const teamName = 'zz-persisted-live-bootstrap-model-unavailable';
@ -6136,6 +6439,144 @@ describe('TeamProvisioningService', () => {
);
});
it('recovers stale mixed secondary lanes from live OpenCode runtime reconcile before degrading them', async () => {
const teamName = 'relay-works-7';
writeTeamMeta(teamName, {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
});
writeMembersMeta(teamName, [
{
name: 'atlas',
providerId: 'opencode',
model: 'opencode/nemotron-3-super-free',
},
{
name: 'bob',
providerId: 'codex',
model: 'gpt-5.4',
},
{
name: 'nova',
providerId: 'codex',
model: 'gpt-5.4',
},
{
name: 'tom',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]);
writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['bob', 'nova']);
writeBootstrapState(teamName, [
{ name: 'bob', status: 'registered' },
{ name: 'nova', status: 'registered' },
]);
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempTeamsBase,
teamName,
laneId: 'secondary:opencode:atlas',
state: 'active',
});
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempTeamsBase,
teamName,
laneId: 'secondary:opencode:tom',
state: 'active',
});
const adapterReconcile = vi.fn(async (input: Record<string, unknown>) => {
const member = (input.expectedMembers as Array<{ name: string }>)[0]?.name;
return {
runId: String(input.runId),
teamName,
launchPhase: 'reconciled',
teamLaunchState: 'clean_success',
members: member
? {
[member]: {
memberName: member,
providerId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
diagnostics: ['bootstrap confirmed'],
},
}
: {},
snapshot: null,
warnings: [],
diagnostics: [],
};
});
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: adapterReconcile,
stop: vi.fn(),
} as any,
])
);
const result = await svc.getMemberSpawnStatuses(teamName);
expect(adapterReconcile).toHaveBeenCalledTimes(2);
expect(adapterReconcile).toHaveBeenCalledWith(
expect.objectContaining({
teamName,
laneId: 'secondary:opencode:atlas',
reason: 'startup_recovery',
expectedMembers: [
expect.objectContaining({
name: 'atlas',
providerId: 'opencode',
cwd: '/Users/test/proj',
}),
],
})
);
expect(adapterReconcile).toHaveBeenCalledWith(
expect.objectContaining({
teamName,
laneId: 'secondary:opencode:tom',
reason: 'startup_recovery',
expectedMembers: [
expect.objectContaining({
name: 'tom',
providerId: 'opencode',
cwd: '/Users/test/proj',
}),
],
})
);
expect(result.expectedMembers).toEqual(expect.arrayContaining(['atlas', 'bob', 'nova', 'tom']));
expect(result.statuses.atlas).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(result.statuses.tom).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
lanes: {
'secondary:opencode:atlas': {
state: 'active',
},
'secondary:opencode:tom': {
state: 'active',
},
},
});
});
it('includes queued OpenCode secondary lanes in live spawn statuses before the final mixed snapshot settles', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox').mockResolvedValue(undefined);
@ -6209,6 +6650,81 @@ describe('TeamProvisioningService', () => {
});
});
it('keeps finished OpenCode secondary lanes pending when runtime evidence has not materialized yet', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox').mockResolvedValue(undefined);
vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined);
const run = createMemberSpawnRun({
teamName: 'mixed-live-finished-no-evidence',
runId: 'run-mixed-live-2',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
livenessSource: 'heartbeat',
}),
],
]),
});
run.isLaunch = true;
run.request = {
teamName: 'mixed-live-finished-no-evidence',
cwd: '/tmp/mixed-live-finished-no-evidence',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
members: [],
};
run.effectiveMembers = [
{
name: 'bob',
providerId: 'codex',
model: 'gpt-5.4',
},
];
run.mixedSecondaryLanes = [
{
laneId: 'secondary:opencode:atlas',
providerId: 'opencode',
member: {
name: 'atlas',
providerId: 'opencode',
model: 'opencode/nemotron-3-super-free',
},
runId: 'lane-run-atlas',
state: 'finished',
result: null,
warnings: [],
diagnostics: [],
},
];
run.detectedSessionId = 'lead-session';
(svc as any).runs.set(run.runId, run);
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
const result = await svc.getMemberSpawnStatuses(run.teamName);
expect(result.teamLaunchState).toBe('partial_pending');
expect(result.expectedMembers).toEqual(expect.arrayContaining(['bob', 'atlas']));
expect(result.statuses.bob).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(result.statuses.atlas).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
hardFailure: false,
hardFailureReason: undefined,
});
});
it('includes queued OpenCode secondary lanes in live spawn statuses during createTeam runs', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox').mockResolvedValue(undefined);