fix(team): streamline opencode secondary lane launch
This commit is contained in:
parent
ad7c4e24ad
commit
511e4178be
5 changed files with 339 additions and 33 deletions
|
|
@ -1591,6 +1591,42 @@ interface MixedSecondaryRuntimeLaneState {
|
|||
result: TeamRuntimeLaunchResult | null;
|
||||
warnings: 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: {
|
||||
|
|
@ -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 {
|
||||
return diagnostics.some((diagnostic) =>
|
||||
/outcome must be reconciled before retry/i.test(diagnostic)
|
||||
|
|
@ -1760,7 +1808,10 @@ function isDefinitiveOpenCodePreLaunchFailure(
|
|||
member.agentToolAccepted ||
|
||||
member.runtimeAlive ||
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -19229,6 +19280,8 @@ export class TeamProvisioningService {
|
|||
run: ProvisioningRun,
|
||||
lane: MixedSecondaryRuntimeLaneState
|
||||
): Promise<void> {
|
||||
lane.launchStartedAtMs = Date.now();
|
||||
lane.queuedAtMs = lane.queuedAtMs ?? lane.launchStartedAtMs;
|
||||
const requestedDiagnostics = [...lane.diagnostics];
|
||||
const shouldAbortLaunch = (): boolean =>
|
||||
run.cancelRequested ||
|
||||
|
|
@ -19250,6 +19303,8 @@ export class TeamProvisioningService {
|
|||
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||
if (!adapter) {
|
||||
const message = 'OpenCode runtime adapter is not registered for mixed team launch.';
|
||||
lane.launchFinishedAtMs = Date.now();
|
||||
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
|
||||
lane.state = 'finished';
|
||||
lane.result = {
|
||||
runId: lane.runId ?? randomUUID(),
|
||||
|
|
@ -19266,14 +19321,14 @@ export class TeamProvisioningService {
|
|||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'opencode_runtime_adapter_missing',
|
||||
diagnostics: [message],
|
||||
diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [...requestedDiagnostics, message],
|
||||
diagnostics: appendDiagnosticOnce([...requestedDiagnostics, message], timingDiagnostic),
|
||||
};
|
||||
lane.warnings = [];
|
||||
lane.diagnostics = [...requestedDiagnostics, message];
|
||||
lane.diagnostics = appendDiagnosticOnce([...requestedDiagnostics, message], timingDiagnostic);
|
||||
await this.publishMixedSecondaryLaneStatusChange(run, lane);
|
||||
lane.state = 'finished';
|
||||
return;
|
||||
|
|
@ -19340,6 +19395,8 @@ export class TeamProvisioningService {
|
|||
providerId: 'opencode',
|
||||
model: lane.member.model,
|
||||
effort: lane.member.effort,
|
||||
runtimeOnly: true,
|
||||
skipReadinessPreflight: true,
|
||||
skipPermissions: run.request.skipPermissions !== false,
|
||||
expectedMembers: [
|
||||
{
|
||||
|
|
@ -19369,16 +19426,49 @@ export class TeamProvisioningService {
|
|||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
lane.result = result;
|
||||
lane.warnings = [...result.warnings];
|
||||
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, ...result.diagnostics];
|
||||
lane.launchFinishedAtMs = Date.now();
|
||||
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
|
||||
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)) {
|
||||
const diagnostics = [
|
||||
...requestedDiagnostics,
|
||||
...migration.diagnostics,
|
||||
...collectRuntimeLaunchFailureDiagnostics(result, lane.member.name),
|
||||
];
|
||||
if (
|
||||
isDefinitiveOpenCodePreLaunchFailure(resultWithTiming, lane.member.name) ||
|
||||
resultWithTiming.teamLaunchState === 'partial_failure'
|
||||
) {
|
||||
const diagnostics = collectOpenCodeSecondaryLaneFailureDiagnostics(
|
||||
resultWithTiming,
|
||||
lane.member.name,
|
||||
appendDiagnosticOnce(
|
||||
[...requestedDiagnostics, ...migration.diagnostics],
|
||||
timingDiagnostic
|
||||
)
|
||||
);
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: run.teamName,
|
||||
|
|
@ -19387,8 +19477,6 @@ export class TeamProvisioningService {
|
|||
diagnostics,
|
||||
}).catch(() => undefined);
|
||||
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||
} else if (result.teamLaunchState === 'partial_failure') {
|
||||
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldAbortLaunch()) {
|
||||
|
|
@ -19396,6 +19484,8 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
lane.launchFinishedAtMs = Date.now();
|
||||
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
|
||||
lane.result = {
|
||||
runId: lane.runId,
|
||||
teamName: run.teamName,
|
||||
|
|
@ -19411,20 +19501,23 @@ export class TeamProvisioningService {
|
|||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: message,
|
||||
diagnostics: [message],
|
||||
diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [message],
|
||||
diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
|
||||
};
|
||||
lane.warnings = [];
|
||||
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, message];
|
||||
lane.diagnostics = appendDiagnosticOnce(
|
||||
[...requestedDiagnostics, ...migration.diagnostics, message],
|
||||
timingDiagnostic
|
||||
);
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: run.teamName,
|
||||
laneId: lane.laneId,
|
||||
state: 'degraded',
|
||||
diagnostics: [message],
|
||||
diagnostics: appendDiagnosticOnce([message], timingDiagnostic),
|
||||
}).catch(() => undefined);
|
||||
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||
}
|
||||
|
|
@ -19486,11 +19579,12 @@ export class TeamProvisioningService {
|
|||
run: ProvisioningRun,
|
||||
lane: MixedSecondaryRuntimeLaneState
|
||||
): void {
|
||||
if (lane.state !== 'queued') {
|
||||
if (lane.state !== 'queued' || lane.launchScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
lane.state = 'launching';
|
||||
lane.queuedAtMs = lane.queuedAtMs ?? Date.now();
|
||||
lane.launchScheduled = true;
|
||||
lane.runId = lane.runId ?? randomUUID();
|
||||
|
||||
const launch = async () => {
|
||||
|
|
@ -19505,6 +19599,7 @@ export class TeamProvisioningService {
|
|||
lane.state = 'finished';
|
||||
return;
|
||||
}
|
||||
lane.state = 'launching';
|
||||
await this.launchSingleMixedSecondaryLane(run, lane);
|
||||
} catch (error) {
|
||||
if (run.cancelRequested || run.processKilled) {
|
||||
|
|
|
|||
|
|
@ -132,25 +132,33 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
);
|
||||
}
|
||||
|
||||
const prepared = await this.prepare(input);
|
||||
if (!prepared.ok) {
|
||||
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
|
||||
const skipReadinessPreflight = input.skipReadinessPreflight === true;
|
||||
let selectedModel = input.model?.trim() ?? '';
|
||||
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) {
|
||||
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) {
|
||||
return blockedLaunchResult(input, 'opencode_model_unavailable', [
|
||||
'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);
|
||||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
runId: input.runId,
|
||||
|
|
@ -169,7 +177,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
manifestHighWatermark: null,
|
||||
});
|
||||
|
||||
return mapOpenCodeLaunchDataToRuntimeResult(input, data, prepared.warnings);
|
||||
return mapOpenCodeLaunchDataToRuntimeResult(input, data, launchWarnings);
|
||||
}
|
||||
|
||||
async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
|
||||
|
|
@ -430,6 +438,9 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
prepareWarnings: string[]
|
||||
): TeamRuntimeLaunchResult {
|
||||
const bridgeDiagnostics = data.diagnostics.map(formatOpenCodeBridgeDiagnostic);
|
||||
const memberBridgeDiagnostics = bridgeDiagnostics.filter(
|
||||
(diagnostic) => !isOpenCodeLaunchTimingDiagnostic(diagnostic)
|
||||
);
|
||||
const checkpointNames = extractCheckpointNames(data);
|
||||
const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) =>
|
||||
checkpointNames.has(name)
|
||||
|
|
@ -492,7 +503,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
...(bridgeMember?.evidence ?? []).map(
|
||||
(evidence) => `${evidence.kind} at ${evidence.observedAt}`
|
||||
),
|
||||
...bridgeDiagnostics,
|
||||
...memberBridgeDiagnostics,
|
||||
...checkpointDiagnostic,
|
||||
...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []),
|
||||
]
|
||||
|
|
@ -724,6 +735,13 @@ function formatOpenCodeBridgeDiagnostic(diagnostic: {
|
|||
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(
|
||||
input: TeamRuntimeLaunchInput,
|
||||
reason: string,
|
||||
|
|
|
|||
|
|
@ -35,10 +35,15 @@ export interface TeamRuntimeLaunchInput {
|
|||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
/**
|
||||
* Runtime-only preflight skips model-scoped execution/evidence checks.
|
||||
* Use only for warm-up diagnostics before a concrete launch model is selected.
|
||||
* Runtime-only readiness skips extra model execution probes.
|
||||
* Use when a separate preflight or the real launch prompt is the authority.
|
||||
*/
|
||||
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;
|
||||
expectedMembers: TeamRuntimeMemberSpec[];
|
||||
previousLaunchState: PersistedTeamLaunchSnapshot | null;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const launchOpenCodeTeam = vi.fn();
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
|
|
|
|||
|
|
@ -3837,6 +3837,8 @@ describe('TeamProvisioningService', () => {
|
|||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
runtimeOnly: true,
|
||||
skipReadinessPreflight: true,
|
||||
cwd: '/tmp/mixed-team',
|
||||
expectedMembers: [
|
||||
expect.objectContaining({
|
||||
|
|
@ -6552,6 +6554,117 @@ describe('TeamProvisioningService', () => {
|
|||
state: 'degraded',
|
||||
diagnostics: expect.arrayContaining([
|
||||
'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(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([
|
||||
'launching',
|
||||
'launching',
|
||||
'launching',
|
||||
'queued',
|
||||
'queued',
|
||||
]);
|
||||
|
||||
await expect(resultPromise).resolves.toBeNull();
|
||||
expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||
await Promise.resolve();
|
||||
expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(1);
|
||||
expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(2);
|
||||
|
||||
resolveFirstLaunch();
|
||||
await Promise.resolve();
|
||||
await vi.waitFor(() => expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(3));
|
||||
|
|
|
|||
Loading…
Reference in a new issue