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;
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 }), {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue