fix(team): streamline opencode secondary lane launch

This commit is contained in:
777genius 2026-05-03 09:30:56 +03:00
parent ad7c4e24ad
commit 511e4178be
5 changed files with 339 additions and 33 deletions

View file

@ -1591,6 +1591,42 @@ interface MixedSecondaryRuntimeLaneState {
result: TeamRuntimeLaunchResult | null; result: TeamRuntimeLaunchResult | null;
warnings: string[]; warnings: string[];
diagnostics: string[]; diagnostics: string[];
launchScheduled?: boolean;
queuedAtMs?: number;
launchStartedAtMs?: number;
launchFinishedAtMs?: number;
}
function formatOpenCodeLaneTimingMs(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${Math.max(0, Math.round(value))}ms`
: 'n/a';
}
function appendDiagnosticOnce(diagnostics: readonly string[], diagnostic: string | null): string[] {
if (!diagnostic || diagnostics.includes(diagnostic)) {
return [...diagnostics];
}
return [...diagnostics, diagnostic];
}
function buildOpenCodeSecondaryLaneTimingDiagnostic(
lane: MixedSecondaryRuntimeLaneState
): string | null {
if (
typeof lane.queuedAtMs !== 'number' ||
typeof lane.launchStartedAtMs !== 'number' ||
typeof lane.launchFinishedAtMs !== 'number'
) {
return null;
}
return [
'OpenCode secondary lane timing:',
`member=${lane.member.name}`,
`queueWaitMs=${formatOpenCodeLaneTimingMs(lane.launchStartedAtMs - lane.queuedAtMs)}`,
`launchMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.launchStartedAtMs)}`,
`totalMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.queuedAtMs)}`,
].join(' ');
} }
function createUnexpectedMixedSecondaryLaneFailureResult(input: { function createUnexpectedMixedSecondaryLaneFailureResult(input: {
@ -1738,6 +1774,18 @@ function collectRuntimeLaunchFailureDiagnostics(
); );
} }
function collectOpenCodeSecondaryLaneFailureDiagnostics(
result: TeamRuntimeLaunchResult,
memberName: string,
prefixDiagnostics: readonly string[]
): string[] {
const diagnostics = [
...prefixDiagnostics,
...collectRuntimeLaunchFailureDiagnostics(result, memberName),
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
return diagnostics.length > 0 ? diagnostics : ['OpenCode bridge reported member launch failure'];
}
function isReconciliableOpenCodeUnknownOutcome(diagnostics: readonly string[]): boolean { function isReconciliableOpenCodeUnknownOutcome(diagnostics: readonly string[]): boolean {
return diagnostics.some((diagnostic) => return diagnostics.some((diagnostic) =>
/outcome must be reconciled before retry/i.test(diagnostic) /outcome must be reconciled before retry/i.test(diagnostic)
@ -1760,7 +1808,10 @@ function isDefinitiveOpenCodePreLaunchFailure(
member.agentToolAccepted || member.agentToolAccepted ||
member.runtimeAlive || member.runtimeAlive ||
member.bootstrapConfirmed || member.bootstrapConfirmed ||
typeof member.sessionId === 'string'; (typeof member.sessionId === 'string' && member.sessionId.trim().length > 0) ||
(typeof member.runtimePid === 'number' &&
Number.isFinite(member.runtimePid) &&
member.runtimePid > 0);
if (runtimeMaterialized) { if (runtimeMaterialized) {
return false; return false;
} }
@ -19229,6 +19280,8 @@ export class TeamProvisioningService {
run: ProvisioningRun, run: ProvisioningRun,
lane: MixedSecondaryRuntimeLaneState lane: MixedSecondaryRuntimeLaneState
): Promise<void> { ): Promise<void> {
lane.launchStartedAtMs = Date.now();
lane.queuedAtMs = lane.queuedAtMs ?? lane.launchStartedAtMs;
const requestedDiagnostics = [...lane.diagnostics]; const requestedDiagnostics = [...lane.diagnostics];
const shouldAbortLaunch = (): boolean => const shouldAbortLaunch = (): boolean =>
run.cancelRequested || run.cancelRequested ||
@ -19250,6 +19303,8 @@ export class TeamProvisioningService {
const adapter = this.getOpenCodeRuntimeAdapter(); const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter) { if (!adapter) {
const message = 'OpenCode runtime adapter is not registered for mixed team launch.'; const message = 'OpenCode runtime adapter is not registered for mixed team launch.';
lane.launchFinishedAtMs = Date.now();
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
lane.state = 'finished'; lane.state = 'finished';
lane.result = { lane.result = {
runId: lane.runId ?? randomUUID(), runId: lane.runId ?? randomUUID(),
@ -19266,14 +19321,14 @@ export class TeamProvisioningService {
bootstrapConfirmed: false, bootstrapConfirmed: false,
hardFailure: true, hardFailure: true,
hardFailureReason: 'opencode_runtime_adapter_missing', hardFailureReason: 'opencode_runtime_adapter_missing',
diagnostics: [message], diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
}, },
}, },
warnings: [], warnings: [],
diagnostics: [...requestedDiagnostics, message], diagnostics: appendDiagnosticOnce([...requestedDiagnostics, message], timingDiagnostic),
}; };
lane.warnings = []; lane.warnings = [];
lane.diagnostics = [...requestedDiagnostics, message]; lane.diagnostics = appendDiagnosticOnce([...requestedDiagnostics, message], timingDiagnostic);
await this.publishMixedSecondaryLaneStatusChange(run, lane); await this.publishMixedSecondaryLaneStatusChange(run, lane);
lane.state = 'finished'; lane.state = 'finished';
return; return;
@ -19340,6 +19395,8 @@ export class TeamProvisioningService {
providerId: 'opencode', providerId: 'opencode',
model: lane.member.model, model: lane.member.model,
effort: lane.member.effort, effort: lane.member.effort,
runtimeOnly: true,
skipReadinessPreflight: true,
skipPermissions: run.request.skipPermissions !== false, skipPermissions: run.request.skipPermissions !== false,
expectedMembers: [ expectedMembers: [
{ {
@ -19369,16 +19426,49 @@ export class TeamProvisioningService {
await finishCancelledLane(); await finishCancelledLane();
return; return;
} }
lane.result = result; lane.launchFinishedAtMs = Date.now();
lane.warnings = [...result.warnings]; const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, ...result.diagnostics]; const memberEvidence = result.members[lane.member.name];
const resultWithTiming: TeamRuntimeLaunchResult = timingDiagnostic
? {
...result,
diagnostics: appendDiagnosticOnce(result.diagnostics, timingDiagnostic),
members: {
...result.members,
...(memberEvidence
? {
[lane.member.name]: {
...memberEvidence,
diagnostics: appendDiagnosticOnce(
memberEvidence.diagnostics ?? [],
timingDiagnostic
),
},
}
: {}),
},
}
: result;
lane.result = resultWithTiming;
lane.warnings = [...resultWithTiming.warnings];
const launchDiagnostics = appendDiagnosticOnce(
[...requestedDiagnostics, ...migration.diagnostics, ...resultWithTiming.diagnostics],
timingDiagnostic
);
lane.diagnostics = launchDiagnostics;
if (isDefinitiveOpenCodePreLaunchFailure(result, lane.member.name)) { if (
const diagnostics = [ isDefinitiveOpenCodePreLaunchFailure(resultWithTiming, lane.member.name) ||
...requestedDiagnostics, resultWithTiming.teamLaunchState === 'partial_failure'
...migration.diagnostics, ) {
...collectRuntimeLaunchFailureDiagnostics(result, lane.member.name), const diagnostics = collectOpenCodeSecondaryLaneFailureDiagnostics(
]; resultWithTiming,
lane.member.name,
appendDiagnosticOnce(
[...requestedDiagnostics, ...migration.diagnostics],
timingDiagnostic
)
);
await upsertOpenCodeRuntimeLaneIndexEntry({ await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(), teamsBasePath: getTeamsBasePath(),
teamName: run.teamName, teamName: run.teamName,
@ -19387,8 +19477,6 @@ export class TeamProvisioningService {
diagnostics, diagnostics,
}).catch(() => undefined); }).catch(() => undefined);
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
} else if (result.teamLaunchState === 'partial_failure') {
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
} }
} catch (error) { } catch (error) {
if (shouldAbortLaunch()) { if (shouldAbortLaunch()) {
@ -19396,6 +19484,8 @@ export class TeamProvisioningService {
return; return;
} }
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
lane.launchFinishedAtMs = Date.now();
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
lane.result = { lane.result = {
runId: lane.runId, runId: lane.runId,
teamName: run.teamName, teamName: run.teamName,
@ -19411,20 +19501,23 @@ export class TeamProvisioningService {
bootstrapConfirmed: false, bootstrapConfirmed: false,
hardFailure: true, hardFailure: true,
hardFailureReason: message, hardFailureReason: message,
diagnostics: [message], diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
}, },
}, },
warnings: [], warnings: [],
diagnostics: [message], diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
}; };
lane.warnings = []; lane.warnings = [];
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, message]; lane.diagnostics = appendDiagnosticOnce(
[...requestedDiagnostics, ...migration.diagnostics, message],
timingDiagnostic
);
await upsertOpenCodeRuntimeLaneIndexEntry({ await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(), teamsBasePath: getTeamsBasePath(),
teamName: run.teamName, teamName: run.teamName,
laneId: lane.laneId, laneId: lane.laneId,
state: 'degraded', state: 'degraded',
diagnostics: [message], diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
}).catch(() => undefined); }).catch(() => undefined);
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
} }
@ -19486,11 +19579,12 @@ export class TeamProvisioningService {
run: ProvisioningRun, run: ProvisioningRun,
lane: MixedSecondaryRuntimeLaneState lane: MixedSecondaryRuntimeLaneState
): void { ): void {
if (lane.state !== 'queued') { if (lane.state !== 'queued' || lane.launchScheduled) {
return; return;
} }
lane.state = 'launching'; lane.queuedAtMs = lane.queuedAtMs ?? Date.now();
lane.launchScheduled = true;
lane.runId = lane.runId ?? randomUUID(); lane.runId = lane.runId ?? randomUUID();
const launch = async () => { const launch = async () => {
@ -19505,6 +19599,7 @@ export class TeamProvisioningService {
lane.state = 'finished'; lane.state = 'finished';
return; return;
} }
lane.state = 'launching';
await this.launchSingleMixedSecondaryLane(run, lane); await this.launchSingleMixedSecondaryLane(run, lane);
} catch (error) { } catch (error) {
if (run.cancelRequested || run.processKilled) { if (run.cancelRequested || run.processKilled) {

View file

@ -132,25 +132,33 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
); );
} }
const prepared = await this.prepare(input); const skipReadinessPreflight = input.skipReadinessPreflight === true;
if (!prepared.ok) { let selectedModel = input.model?.trim() ?? '';
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); let launchWarnings: string[] = [];
if (!skipReadinessPreflight) {
const prepared = await this.prepare(input);
if (!prepared.ok) {
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
}
selectedModel = prepared.modelId ?? selectedModel;
launchWarnings = prepared.warnings;
} }
if (!this.bridge.launchOpenCodeTeam) { if (!this.bridge.launchOpenCodeTeam) {
return blockedLaunchResult(input, 'opencode_launch_bridge_missing', [ return blockedLaunchResult(input, 'opencode_launch_bridge_missing', [
'OpenCode readiness passed, but the state-changing launch bridge is not registered.', 'OpenCode state-changing launch bridge is not registered.',
]); ]);
} }
const selectedModel = prepared.modelId ?? input.model?.trim() ?? '';
if (!selectedModel) { if (!selectedModel) {
return blockedLaunchResult(input, 'opencode_model_unavailable', [ return blockedLaunchResult(input, 'opencode_model_unavailable', [
'OpenCode launch requires a selected raw model id.', 'OpenCode launch requires a selected raw model id.',
]); ]);
} }
const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; const runtimeSnapshot = skipReadinessPreflight
? null
: (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null);
this.lastProjectPathByTeamName.set(input.teamName, input.cwd); this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
const data = await this.bridge.launchOpenCodeTeam({ const data = await this.bridge.launchOpenCodeTeam({
runId: input.runId, runId: input.runId,
@ -169,7 +177,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
manifestHighWatermark: null, manifestHighWatermark: null,
}); });
return mapOpenCodeLaunchDataToRuntimeResult(input, data, prepared.warnings); return mapOpenCodeLaunchDataToRuntimeResult(input, data, launchWarnings);
} }
async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> { async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
@ -430,6 +438,9 @@ function mapOpenCodeLaunchDataToRuntimeResult(
prepareWarnings: string[] prepareWarnings: string[]
): TeamRuntimeLaunchResult { ): TeamRuntimeLaunchResult {
const bridgeDiagnostics = data.diagnostics.map(formatOpenCodeBridgeDiagnostic); const bridgeDiagnostics = data.diagnostics.map(formatOpenCodeBridgeDiagnostic);
const memberBridgeDiagnostics = bridgeDiagnostics.filter(
(diagnostic) => !isOpenCodeLaunchTimingDiagnostic(diagnostic)
);
const checkpointNames = extractCheckpointNames(data); const checkpointNames = extractCheckpointNames(data);
const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) => const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) =>
checkpointNames.has(name) checkpointNames.has(name)
@ -492,7 +503,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
...(bridgeMember?.evidence ?? []).map( ...(bridgeMember?.evidence ?? []).map(
(evidence) => `${evidence.kind} at ${evidence.observedAt}` (evidence) => `${evidence.kind} at ${evidence.observedAt}`
), ),
...bridgeDiagnostics, ...memberBridgeDiagnostics,
...checkpointDiagnostic, ...checkpointDiagnostic,
...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []), ...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []),
] ]
@ -724,6 +735,13 @@ function formatOpenCodeBridgeDiagnostic(diagnostic: {
return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`; return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`;
} }
function isOpenCodeLaunchTimingDiagnostic(diagnostic: string): boolean {
return (
diagnostic.startsWith('info:opencode_launch_member_timing:') ||
diagnostic.startsWith('info:opencode_launch_total_timing:')
);
}
function blockedLaunchResult( function blockedLaunchResult(
input: TeamRuntimeLaunchInput, input: TeamRuntimeLaunchInput,
reason: string, reason: string,

View file

@ -35,10 +35,15 @@ export interface TeamRuntimeLaunchInput {
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** /**
* Runtime-only preflight skips model-scoped execution/evidence checks. * Runtime-only readiness skips extra model execution probes.
* Use only for warm-up diagnostics before a concrete launch model is selected. * Use when a separate preflight or the real launch prompt is the authority.
*/ */
runtimeOnly?: boolean; runtimeOnly?: boolean;
/**
* Skip the adapter-level readiness bridge before launch.
* Use only when the launch bridge performs equivalent runtime/provider/MCP validation.
*/
skipReadinessPreflight?: boolean;
skipPermissions: boolean; skipPermissions: boolean;
expectedMembers: TeamRuntimeMemberSpec[]; expectedMembers: TeamRuntimeMemberSpec[];
previousLaunchState: PersistedTeamLaunchSnapshot | null; previousLaunchState: PersistedTeamLaunchSnapshot | null;

View file

@ -86,6 +86,76 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
}); });
}); });
it('can rely on the launch bridge as the only readiness authority', async () => {
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>(
async () =>
({
runId: 'run-1',
teamLaunchState: 'ready',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'confirmed_alive',
runtimePid: 123,
model: 'openai/gpt-5.4-mini',
evidence: [
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
],
},
},
warnings: [],
diagnostics: [
{
code: 'opencode_launch_total_timing',
severity: 'info',
message: 'total=12ms provisioningProbe=3ms members=1',
},
{
code: 'member_reconcile',
severity: 'warning',
message: 'alice: sample reconcile diagnostic',
},
],
}) satisfies OpenCodeLaunchTeamCommandData
);
const bridge = bridgePort(
readiness({
state: 'unknown_error',
launchAllowed: false,
diagnostics: ['readiness should be skipped'],
}),
{ launchOpenCodeTeam }
);
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
const result = await adapter.launch(launchInput({ skipReadinessPreflight: true }));
expect(result.teamLaunchState).toBe('clean_success');
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
expect.objectContaining({
selectedModel: 'openai/gpt-5.4-mini',
expectedCapabilitySnapshotId: null,
})
);
expect(result.diagnostics).toEqual(
expect.arrayContaining([
'info:opencode_launch_total_timing: total=12ms provisioningProbe=3ms members=1',
])
);
expect(result.members.alice?.diagnostics).not.toContain(
'info:opencode_launch_total_timing: total=12ms provisioningProbe=3ms members=1'
);
expect(result.members.alice?.diagnostics).toContain(
'warning:member_reconcile: alice: sample reconcile diagnostic'
);
});
it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => { it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => {
const launchOpenCodeTeam = vi.fn(); const launchOpenCodeTeam = vi.fn();
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {

View file

@ -3837,6 +3837,8 @@ describe('TeamProvisioningService', () => {
providerId: 'opencode', providerId: 'opencode',
model: 'minimax-m2.5-free', model: 'minimax-m2.5-free',
effort: 'medium', effort: 'medium',
runtimeOnly: true,
skipReadinessPreflight: true,
cwd: '/tmp/mixed-team', cwd: '/tmp/mixed-team',
expectedMembers: [ expectedMembers: [
expect.objectContaining({ expect.objectContaining({
@ -6552,6 +6554,117 @@ describe('TeamProvisioningService', () => {
state: 'degraded', state: 'degraded',
diagnostics: expect.arrayContaining([ diagnostics: expect.arrayContaining([
'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out', 'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out',
expect.stringMatching(
/^OpenCode secondary lane timing: member=bob queueWaitMs=\d+ms launchMs=\d+ms totalMs=\d+ms$/
),
]),
},
},
});
},
{ timeout: 5000 }
);
});
it('marks an OpenCode secondary lane degraded when launch fails after runtime materializes', async () => {
const teamName = 'mixed-runtime-materialized-failure';
const svc = new TeamProvisioningService();
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => ({
runId: String(input.runId),
teamName: String(input.teamName),
launchPhase: 'active',
teamLaunchState: 'partial_failure',
members: {
tom: {
memberName: 'tom',
providerId: 'opencode',
launchState: 'failed_to_start',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'OpenCode bridge reported member launch failure',
sessionId: 'ses_tom_materialized_without_bootstrap',
runtimePid: 71388,
livenessKind: 'runtime_process',
diagnostics: [
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
],
},
},
warnings: [],
diagnostics: ['OpenCode bridge reported member launch failure'],
}));
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
])
);
(svc as any).launchStateStore = {
read: vi.fn(async () => null),
write: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
};
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['bob'],
});
run.isLaunch = true;
run.request = {
teamName,
cwd: '/tmp/mixed-runtime-materialized-failure',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
skipPermissions: true,
};
run.effectiveMembers = [
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
},
];
run.mixedSecondaryLanes = [
{
laneId: 'secondary:opencode:tom',
providerId: 'opencode',
member: {
name: 'tom',
role: 'Developer',
providerId: 'opencode',
model: 'minimax-m2.5-free',
effort: 'medium',
},
runId: null,
state: 'queued',
result: null,
warnings: [],
diagnostics: [],
},
];
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor(
async () => {
expect(adapterLaunch).toHaveBeenCalledTimes(1);
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
lanes: {
'secondary:opencode:tom': {
state: 'degraded',
diagnostics: expect.arrayContaining([
'OpenCode bridge reported member launch failure',
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
]), ]),
}, },
}, },
@ -6670,13 +6783,18 @@ describe('TeamProvisioningService', () => {
expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(1); expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(1);
expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([
'launching', 'launching',
'launching', 'queued',
'launching', 'queued',
]); ]);
await expect(resultPromise).resolves.toBeNull(); await expect(resultPromise).resolves.toBeNull();
expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(1); expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(1);
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await Promise.resolve();
expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(1);
expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(2);
resolveFirstLaunch(); resolveFirstLaunch();
await Promise.resolve(); await Promise.resolve();
await vi.waitFor(() => expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(3)); await vi.waitFor(() => expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(3));