fix: stabilize opencode team launch recovery

This commit is contained in:
777genius 2026-04-23 18:27:03 +03:00
parent f4e4ecca2e
commit 9ebc4368d0
31 changed files with 4628 additions and 189 deletions

View file

@ -23,7 +23,8 @@
"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",
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.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,192 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
export async function preflightOpenCodeLiveEnvironment(input) {
const repoRoot = input.repoRoot;
const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode';
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
const xdgDataHome = path.join(tempRoot, 'xdg-data');
const env = {
...process.env,
XDG_DATA_HOME: xdgDataHome,
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
try {
if (!fs.existsSync(opencodeBin)) {
return skip(`OpenCode binary not found at ${opencodeBin}`);
}
const models = runOpenCodeCommand(opencodeBin, ['models'], repoRoot, env);
if (!models.ok) {
return skip(`opencode models failed: ${models.output}`);
}
const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env);
if (!agents.ok) {
return skip(`opencode agent list failed: ${agents.output}`);
}
const loopback = await canBindLoopback();
if (!loopback.ok) {
return skip(`127.0.0.1 loopback bind failed: ${loopback.reason}`);
}
const host = await canStartOpenCodeHost(opencodeBin, repoRoot, env);
if (!host.ok) {
return skip(`opencode serve health check failed: ${host.reason}`);
}
return { ok: true };
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
export function exitForSkippedPreflight(result) {
if (result.ok) {
return false;
}
console.warn(`SKIPPED: ${result.reason}`);
process.exit(process.env.OPENCODE_E2E_STRICT === '1' ? 1 : 0);
}
function runOpenCodeCommand(opencodeBin, args, cwd, env) {
const result = spawnSync(opencodeBin, args, {
cwd,
env,
encoding: 'utf8',
timeout: 20_000,
maxBuffer: 256_000,
});
if (result.status === 0) {
return { ok: true, output: '' };
}
return {
ok: false,
output: compactOutput(result.stderr || result.stdout || result.error?.message || 'unknown'),
};
}
function canBindLoopback() {
return new Promise((resolve) => {
const server = net.createServer();
const timeout = setTimeout(() => {
server.close(() => undefined);
resolve({ ok: false, reason: 'timed out allocating loopback port' });
}, 5_000);
server.once('error', (error) => {
clearTimeout(timeout);
resolve({ ok: false, reason: error.message });
});
server.listen(0, '127.0.0.1', () => {
clearTimeout(timeout);
server.close((error) => {
resolve(error ? { ok: false, reason: error.message } : { ok: true });
});
});
});
}
async function canStartOpenCodeHost(opencodeBin, cwd, env) {
const port = await allocateLoopbackPort();
const child = spawn(opencodeBin, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
let spawnError = '';
const append = (chunk) => {
output = compactOutput(`${output}\n${chunk.toString('utf8')}`);
};
child.stdout?.on('data', append);
child.stderr?.on('data', append);
child.once('error', (error) => {
spawnError = error.message;
append(error.message);
});
try {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (spawnError) {
return { ok: false, reason: spawnError };
}
if (child.exitCode != null) {
return { ok: false, reason: output || `process exited with code ${child.exitCode}` };
}
try {
const response = await fetch(`http://127.0.0.1:${port}/global/health`);
if (response.ok) {
const data = await response.json().catch(() => ({}));
if (data?.healthy === true) {
return { ok: true };
}
}
} catch {
// Host is still starting.
}
await sleep(250);
}
return { ok: false, reason: output || 'timed out waiting for /global/health' };
} finally {
await stopChild(child);
}
}
function stopChild(child) {
return new Promise((resolve) => {
if (child.exitCode != null || child.killed) {
resolve();
return;
}
const timeout = setTimeout(() => {
if (child.exitCode == null) {
child.kill('SIGKILL');
}
resolve();
}, 3_000);
child.once('close', () => {
clearTimeout(timeout);
resolve();
});
child.kill('SIGTERM');
});
}
function allocateLoopbackPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('failed to allocate loopback port')));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function skip(reason) {
return { ok: false, reason };
}
function compactOutput(value) {
return value.replace(/\s+/g, ' ').trim().slice(0, 1_200);
}

View file

@ -5,6 +5,11 @@ import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
exitForSkippedPreflight,
preflightOpenCodeLiveEnvironment,
} from './lib/opencode-live-preflight.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
@ -14,6 +19,7 @@ const env = {
...process.env,
OPENCODE_E2E: '1',
OPENCODE_E2E_MIXED_RECOVERY: '1',
OPENCODE_E2E_MIXED_RECOVERY_MULTI: process.env.OPENCODE_E2E_MIXED_RECOVERY_MULTI ?? '0',
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',
@ -28,6 +34,10 @@ 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}`);
console.log(`Multi-lane: ${env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? 'enabled' : 'disabled'}`);
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
exitForSkippedPreflight(preflight);
const result = spawnSync(
'pnpm',

View file

@ -0,0 +1,65 @@
#!/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';
import {
exitForSkippedPreflight,
preflightOpenCodeLiveEnvironment,
} from './lib/opencode-live-preflight.mjs';
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_TEAM_PROVISIONING: '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 team provisioning 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 preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
exitForSkippedPreflight(preflight);
const result = spawnSync(
'pnpm',
[
'exec',
'vitest',
'run',
'--maxWorkers',
'1',
'--minWorkers',
'1',
'test/main/services/team/OpenCodeTeamProvisioning.live.test.ts',
],
{
cwd: repoRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
}
);
if (result.error) {
console.error(`Failed to run OpenCode team provisioning smoke: ${result.error.message}`);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -129,6 +129,7 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
runtimePid: 333,
diagnostics: ['spawn accepted', 'late heartbeat received'],
},
},
@ -143,6 +144,7 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
runtimePid: 333,
});
expect(snapshot.summary).toEqual({
confirmedCount: 2,

View file

@ -37,6 +37,7 @@ export interface MixedSecondaryLaneMemberStateInput {
hardFailure?: boolean;
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
runtimePid?: number;
diagnostics?: string[];
} | null;
pendingReason?: string;
@ -217,6 +218,12 @@ function createSecondaryLaneMemberState(
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length
? [...new Set(evidence.pendingPermissionRequestIds)]
: undefined,
runtimePid:
typeof evidence?.runtimePid === 'number' &&
Number.isFinite(evidence.runtimePid) &&
evidence.runtimePid > 0
? Math.trunc(evidence.runtimePid)
: undefined,
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,

View file

@ -2553,14 +2553,15 @@ async function handleSendMessage(
});
// Teammate inbox relay DISABLED (2026-03-23).
// Teammates read their own inbox files directly via fs.watch — confirmed empirically.
// Codex/Claude teammates read their own inbox files directly via fs.watch.
// Relaying through the lead (relayMemberInboxMessages) caused multiple bugs:
// 1. Lead responded to user instead of forwarding to the teammate
// 2. Duplicate messages (relay loop: markInboxMessagesRead → FileWatcher → relay again)
// 3. Fragile LLM-dependent prompt chain for routing
// The message is already persisted in inboxes/{member}.json above — that's sufficient.
// The message is already persisted in inboxes/{member}.json above.
// Teammate responses go to inboxes/user.json and are read by TeamInboxReader.
// Lead relay (relayLeadInboxMessages) is still needed — lead reads stdin only, not inbox.
// Lead relay (relayLeadInboxMessages) is still needed because lead reads stdin only, not inbox.
// OpenCode secondary lanes do not watch these inbox files, so they need runtime bridge delivery.
//
// if (!isLeadRecipient && isAlive) {
// try {
@ -2569,6 +2570,29 @@ async function handleSendMessage(
// logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
// }
// }
if (!isLeadRecipient && isAlive) {
void provisioning
.deliverOpenCodeMemberMessage(tn, {
memberName,
text: memberDeliveryText,
messageId: result.messageId,
})
.then((delivery) => {
if (delivery.delivered || delivery.reason === 'recipient_is_not_opencode') {
return;
}
logger.warn(
`OpenCode runtime delivery after sendMessage failed for teammate "${memberName}": ${
delivery.reason ?? 'unknown error'
}`
);
})
.catch((e: unknown) =>
logger.warn(
`OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${String(e)}`
)
);
}
// Best-effort relay for lead via inbox
if (isLeadRecipient && isAlive) {

View file

@ -53,6 +53,12 @@ function normalizePendingPermissionRequestIds(value: unknown): string[] | undefi
return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
}
function normalizeRuntimePid(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.trunc(value)
: undefined;
}
function normalizeMemberName(name: string): string {
return name.trim();
}
@ -333,6 +339,7 @@ function normalizePersistedMemberState(
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
parsed.pendingPermissionRequestIds
),
runtimePid: normalizeRuntimePid(parsed.runtimePid),
firstSpawnAcceptedAt:
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
lastHeartbeatAt:
@ -395,12 +402,18 @@ export function createPersistedLaunchSnapshot(params: {
if (launchPhase !== 'active') {
for (const name of expectedMembers) {
const member = members[name];
const isRecoverableOpenCodeSecondaryLane =
member?.laneKind === 'secondary' &&
member.laneOwnerProviderId === 'opencode' &&
typeof member.laneId === 'string' &&
member.laneId.trim().length > 0;
if (
member?.launchState === 'starting' &&
!member.agentToolAccepted &&
!member.runtimeAlive &&
!member.bootstrapConfirmed &&
!member.hardFailure
!member.hardFailure &&
!isRecoverableOpenCodeSecondaryLane
) {
member.hardFailure = true;
member.hardFailureReason =

View file

@ -76,7 +76,10 @@ import {
} from '@shared/utils/teammateMessageParser';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import {
extractToolPreview,
extractToolResultPreview,
@ -134,6 +137,7 @@ import { TeamInboxReader } from './TeamInboxReader';
import { TeamInboxWriter } from './TeamInboxWriter';
import {
createPersistedLaunchSnapshot,
deriveTeamLaunchAggregateState,
hasMixedPersistedLaunchMetadata,
snapshotFromRuntimeMemberStatuses,
snapshotToMemberSpawnStatuses,
@ -146,6 +150,8 @@ import { TeamMetaStore } from './TeamMetaStore';
import {
TeamRuntimeAdapterRegistry,
type TeamLaunchRuntimeAdapter,
type OpenCodeTeamRuntimeMessageInput,
type OpenCodeTeamRuntimeMessageResult,
type TeamRuntimeLaunchInput,
type TeamRuntimeLaunchResult,
type TeamRuntimeMemberLaunchEvidence,
@ -1389,6 +1395,39 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean {
);
}
function summarizeMemberSpawnStatusRecord(
expectedMembers: readonly string[],
statuses: Record<string, MemberSpawnStatusEntry>
): PersistedTeamLaunchSummary {
let confirmedCount = 0;
let pendingCount = 0;
let failedCount = 0;
let runtimeAlivePendingCount = 0;
const memberNames = Array.from(new Set([...expectedMembers, ...Object.keys(statuses)]));
for (const memberName of memberNames) {
const entry = statuses[memberName];
if (!entry) {
pendingCount += 1;
continue;
}
if (entry.launchState === 'confirmed_alive') {
confirmedCount += 1;
continue;
}
if (entry.launchState === 'failed_to_start') {
failedCount += 1;
continue;
}
pendingCount += 1;
if (entry.runtimeAlive) {
runtimeAlivePendingCount += 1;
}
}
return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount };
}
function buildRestartStillRunningReason(memberName: string): string {
return (
`Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
@ -3881,6 +3920,94 @@ export class TeamProvisioningService {
return this.runtimeAdapterRegistry.get('opencode');
}
private getOpenCodeRuntimeMessageAdapter():
| (TeamLaunchRuntimeAdapter & {
sendMessageToMember(
input: OpenCodeTeamRuntimeMessageInput
): Promise<OpenCodeTeamRuntimeMessageResult>;
})
| null {
const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter || !('sendMessageToMember' in adapter)) {
return null;
}
return adapter as TeamLaunchRuntimeAdapter & {
sendMessageToMember(
input: OpenCodeTeamRuntimeMessageInput
): Promise<OpenCodeTeamRuntimeMessageResult>;
};
}
async deliverOpenCodeMemberMessage(
teamName: string,
input: {
memberName: string;
text: string;
messageId?: string;
}
): Promise<{ delivered: boolean; reason?: string; diagnostics?: string[] }> {
const adapter = this.getOpenCodeRuntimeMessageAdapter();
if (!adapter) {
return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' };
}
const [config, teamMeta, metaMembers] = await Promise.all([
this.configReader.getConfig(teamName).catch(() => null),
this.teamMetaStore.getMeta(teamName).catch(() => null),
this.membersMetaStore.getMembers(teamName).catch(() => []),
]);
const normalizedMemberName = input.memberName.trim();
const configMember = config?.members?.find(
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
);
const metaMember = metaMembers.find(
(member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase()
);
const providerId =
normalizeOptionalTeamProviderId(metaMember?.providerId) ??
normalizeOptionalTeamProviderId(configMember?.providerId) ??
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
if (providerId !== 'opencode') {
return { delivered: false, reason: 'recipient_is_not_opencode' };
}
const leadMember = config?.members?.find((member) => isLeadMember(member));
const leadProviderId =
normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ??
normalizeOptionalTeamProviderId(teamMeta?.providerId) ??
normalizeOptionalTeamProviderId(leadMember?.providerId);
const laneIdentity = buildPlannedMemberLaneIdentity({
leadProviderId,
member: {
name: normalizedMemberName,
providerId,
},
});
const cwd =
config?.projectPath?.trim() ||
metaMember?.cwd?.trim() ||
configMember?.cwd?.trim() ||
this.readPersistedTeamProjectPath(teamName);
if (!cwd) {
return { delivered: false, reason: 'opencode_project_path_unavailable' };
}
const result = await adapter.sendMessageToMember({
runId: this.getTrackedRunId(teamName) ?? randomUUID(),
teamName,
laneId: laneIdentity.laneId,
memberName: normalizedMemberName,
cwd,
text: input.text,
messageId: input.messageId,
});
return {
delivered: result.ok,
...(result.ok ? {} : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
diagnostics: result.diagnostics,
};
}
private shouldRouteOpenCodeToRuntimeAdapter(request: {
providerId?: TeamProviderId;
members?: readonly { providerId?: TeamProviderId; provider?: TeamProviderId }[];
@ -6274,26 +6401,34 @@ export class TeamProvisioningService {
summary?: PersistedTeamLaunchSummary;
source?: 'live' | 'persisted' | 'merged';
}> {
const readPersistedStatuses = async (resolvedRunId: string | null) => {
const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName);
const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses);
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
const summary = expectedMembers
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
: undefined;
return {
statuses: nextStatuses,
runId: resolvedRunId,
teamLaunchState: summary
? deriveTeamLaunchAggregateState(summary)
: snapshot?.teamLaunchState,
launchPhase: snapshot?.launchPhase,
expectedMembers,
updatedAt: snapshot?.updatedAt,
summary: summary ?? snapshot?.summary,
source: 'persisted' as const,
};
};
const runId = this.getTrackedRunId(teamName);
if (!runId) {
return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => {
return this.attachLiveRuntimeMetadataToStatuses(teamName, statuses).then(
(nextStatuses) => ({
statuses: nextStatuses,
runId: null,
teamLaunchState: snapshot?.teamLaunchState,
launchPhase: snapshot?.launchPhase,
expectedMembers: snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined,
updatedAt: snapshot?.updatedAt,
summary: snapshot?.summary,
source: snapshot ? 'persisted' : 'persisted',
})
);
});
return readPersistedStatuses(null);
}
const run = this.runs.get(runId);
if (!run) {
return { statuses: {}, runId: null, source: 'persisted' };
return readPersistedStatuses(runId);
}
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
@ -6313,19 +6448,21 @@ export class TeamProvisioningService {
launchPhase: run.provisioningComplete ? 'finished' : 'active',
statuses: this.buildRuntimeSpawnStatusRecord(run),
});
const snapshot = persisted ?? liveSnapshot;
const snapshot = liveSnapshot ?? persisted;
const statuses = await this.attachLiveRuntimeMetadataToStatuses(
teamName,
snapshotToMemberSpawnStatuses(snapshot)
);
const expectedMembers = this.getPersistedLaunchMemberNames(snapshot);
const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses);
return {
statuses,
runId,
teamLaunchState: snapshot.teamLaunchState,
teamLaunchState: deriveTeamLaunchAggregateState(summary),
launchPhase: snapshot.launchPhase,
expectedMembers: this.getPersistedLaunchMemberNames(snapshot),
expectedMembers,
updatedAt: snapshot.updatedAt,
summary: snapshot.summary,
summary,
source: persisted ? 'merged' : 'live',
};
}
@ -6456,9 +6593,18 @@ export class TeamProvisioningService {
false
);
const rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid;
const isOpenCodeMember =
(launchMember?.providerId ?? normalizeOptionalTeamProviderId(member.providerId)) ===
'opencode';
const runtimeModel =
liveRuntimeMember?.model ??
launchMember?.model?.trim() ??
member.model?.trim() ??
undefined;
const memberProviderId =
launchMember?.providerId ??
normalizeOptionalTeamProviderId(member.providerId) ??
inferTeamProviderIdFromModel(runtimeModel) ??
inferTeamProviderIdFromModel(launchMember?.model) ??
inferTeamProviderIdFromModel(member.model);
const isOpenCodeMember = memberProviderId === 'opencode';
const isSharedOpenCodeHost =
isOpenCodeMember &&
!liveRuntimeMember?.pid &&
@ -6470,11 +6616,6 @@ export class TeamProvisioningService {
: isSharedOpenCodeHost
? false
: backendType !== 'in-process';
const runtimeModel =
liveRuntimeMember?.model ??
launchMember?.model?.trim() ??
member.model?.trim() ??
undefined;
const launchSnapshotAlive =
this.isTeamAlive(teamName) &&
(launchMember?.runtimeAlive === true ||
@ -6498,7 +6639,7 @@ export class TeamProvisioningService {
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive,
restartable,
...(backendType ? { backendType } : {}),
...(launchMember?.providerId ? { providerId: launchMember.providerId } : {}),
...(memberProviderId ? { providerId: memberProviderId } : {}),
...(launchMember?.providerBackendId
? { providerBackendId: launchMember.providerBackendId }
: {}),
@ -10214,6 +10355,9 @@ export class TeamProvisioningService {
// SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete
// team files during cleanup. SIGKILL is uncatchable — files are preserved.
killTeamProcess(run.child);
if (this.hasSecondaryRuntimeRuns(run.teamName)) {
void this.stopMixedSecondaryRuntimeLanes(run.teamName);
}
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
run.onProgress(progress);
this.cleanupRun(run);
@ -11447,6 +11591,20 @@ export class TeamProvisioningService {
...(metadata.model ? { runtimeModel: metadata.model } : {}),
};
const failureReason = current.hardFailureReason ?? current.error;
if (
metadata.alive &&
current.hardFailure !== true &&
current.launchState !== 'failed_to_start'
) {
nextEntry.status = 'online';
nextEntry.agentToolAccepted = true;
nextEntry.runtimeAlive = true;
nextEntry.hardFailure = false;
nextEntry.hardFailureReason = undefined;
nextEntry.error = undefined;
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
}
if (
metadata.alive &&
current.launchState === 'failed_to_start' &&
@ -11817,6 +11975,38 @@ export class TeamProvisioningService {
});
}
const shouldReadPersistedOpenCodeLaunchSnapshot =
(run?.mixedSecondaryLanes?.length ?? 0) > 0 ||
configuredMembers.some(
(member) => normalizeOptionalTeamProviderId(member.providerId) === 'opencode'
) ||
metaMembers.some(
(member) => normalizeOptionalTeamProviderId(member.providerId) === 'opencode'
);
const persistedLaunchSnapshot = shouldReadPersistedOpenCodeLaunchSnapshot
? await this.launchStateStore.read(teamName).catch(() => null)
: null;
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
const memberName = persistedMember.name?.trim() ?? '';
if (
!memberName ||
this.isMemberRemovedInMeta(metaMembers, memberName) ||
persistedMember.providerId !== 'opencode' ||
persistedMember.laneKind !== 'secondary' ||
persistedMember.laneOwnerProviderId !== 'opencode'
) {
continue;
}
upsertMetadata(memberName, {
backendType: 'process',
alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true,
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}),
...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
? { metricsPid: persistedMember.runtimePid }
: {}),
});
}
const paneIds = [...metadataByMember.values()]
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
.filter((paneId) => paneId.length > 0);
@ -12331,6 +12521,7 @@ export class TeamProvisioningService {
hardFailure: evidenceEntry.hardFailure,
hardFailureReason: evidenceEntry.hardFailureReason,
pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds,
runtimePid: evidenceEntry.runtimePid,
diagnostics: evidenceEntry.diagnostics,
}
: finishedWithoutRuntimeEvidence
@ -12363,6 +12554,29 @@ export class TeamProvisioningService {
return hasMixedPersistedLaunchMetadata(snapshot);
}
private shouldRecoverStalePersistedMixedLaunchSnapshot(
snapshot: PersistedTeamLaunchSnapshot
): boolean {
if (snapshot.teamLaunchState !== 'partial_pending') {
return false;
}
const updatedAtMs = Date.parse(snapshot.updatedAt);
if (Number.isFinite(updatedAtMs) && Date.now() - updatedAtMs < MEMBER_LAUNCH_GRACE_MS) {
return false;
}
return Object.values(snapshot.members).some((member) => {
if (member.launchState === 'confirmed_alive' || member.launchState === 'failed_to_start') {
return false;
}
return (
member.laneKind === 'secondary' &&
member.laneOwnerProviderId === 'opencode' &&
typeof member.laneId === 'string'
);
});
}
private async persistLaunchStateSnapshot(
run: ProvisioningRun,
launchPhase: 'active' | 'finished' | 'reconciled' = run.provisioningComplete
@ -12474,6 +12688,10 @@ export class TeamProvisioningService {
],
previousLaunchState,
});
if (run.cancelRequested || run.processKilled) {
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
return;
}
lane.state = 'finished';
lane.result = result;
lane.warnings = [...result.warnings];
@ -12496,6 +12714,10 @@ export class TeamProvisioningService {
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
}
} catch (error) {
if (run.cancelRequested || run.processKilled) {
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
return;
}
const message = error instanceof Error ? error.message : String(error);
lane.state = 'finished';
lane.result = {
@ -12591,6 +12813,10 @@ export class TeamProvisioningService {
try {
await this.launchSingleMixedSecondaryLane(run, lane);
} catch (error) {
if (run.cancelRequested || run.processKilled) {
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
return;
}
const message = error instanceof Error ? error.message : String(error);
logger.warn(
`[${run.teamName}] OpenCode secondary lane ${lane.laneId} crashed during launch orchestration: ${message}`
@ -12620,6 +12846,10 @@ export class TeamProvisioningService {
private async launchMixedSecondaryLaneIfNeeded(
run: ProvisioningRun
): Promise<PersistedTeamLaunchSnapshot | null> {
if (run.cancelRequested || run.processKilled) {
return this.launchStateStore.read(run.teamName).catch(() => null);
}
const mixedSecondaryLanes = run.mixedSecondaryLanes ?? [];
if (mixedSecondaryLanes.length === 0) {
return this.persistLaunchStateSnapshot(run, 'finished');
@ -12668,7 +12898,11 @@ export class TeamProvisioningService {
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null,
persistedSnapshot: PersistedTeamLaunchSnapshot | null
): Promise<PersistedTeamLaunchSnapshot | null> {
if (persistedSnapshot && this.hasMixedLaunchMetadata(persistedSnapshot)) {
if (
persistedSnapshot &&
this.hasMixedLaunchMetadata(persistedSnapshot) &&
!this.shouldRecoverStalePersistedMixedLaunchSnapshot(persistedSnapshot)
) {
return persistedSnapshot;
}
@ -12730,6 +12964,7 @@ export class TeamProvisioningService {
hardFailure?: boolean;
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
runtimePid?: number;
diagnostics?: string[];
};
pendingReason?: string;
@ -12776,6 +13011,7 @@ export class TeamProvisioningService {
hardFailure: runtimeEvidence.hardFailure,
hardFailureReason: runtimeEvidence.hardFailureReason,
pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds,
runtimePid: runtimeEvidence.runtimePid,
diagnostics: runtimeEvidence.diagnostics,
},
});
@ -16465,7 +16701,7 @@ export class TeamProvisioningService {
peekAutoResumeService()?.cancelPendingAutoResume(run.teamName);
}
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete) {
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) {
void this.persistLaunchStateSnapshot(run, 'finished');
}
this.resetRuntimeToolActivity(run);

View file

@ -9,6 +9,7 @@ export type OpenCodeBridgeCommandName =
| 'opencode.launchTeam'
| 'opencode.reconcileTeam'
| 'opencode.stopTeam'
| 'opencode.sendMessage'
| 'opencode.answerPermission'
| 'opencode.listRuntimePermissions'
| 'opencode.getRuntimeTranscript'
@ -118,6 +119,27 @@ export interface OpenCodeStopTeamCommandData {
runtimeStoreManifestHighWatermark?: number | null;
}
export interface OpenCodeSendMessageCommandBody {
runId?: string;
laneId: string;
teamId: string;
teamName: string;
projectPath: string;
memberName: string;
text: string;
messageId?: string;
agent?: string;
noReply?: boolean;
}
export interface OpenCodeSendMessageCommandData {
accepted: boolean;
sessionId?: string;
memberName: string;
runtimePid?: number;
diagnostics: OpenCodeTeamBridgeDiagnostic[];
}
export type OpenCodeBridgePeerName = 'claude_team' | 'agent_teams_orchestrator';
export type OpenCodeBridgeFailureKind =
@ -258,6 +280,7 @@ const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
'opencode.launchTeam',
'opencode.reconcileTeam',
'opencode.stopTeam',
'opencode.sendMessage',
'opencode.answerPermission',
'opencode.listRuntimePermissions',
'opencode.getRuntimeTranscript',

View file

@ -22,6 +22,8 @@ import type {
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeReconcileTeamCommandBody,
OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData,
OpenCodeStopTeamCommandBody,
OpenCodeStopTeamCommandData,
OpenCodeTeamLaunchMode,
@ -46,6 +48,7 @@ export interface OpenCodeReadinessBridgeOptions {
timeoutMs?: number;
launchTimeoutMs?: number;
reconcileTimeoutMs?: number;
sendTimeoutMs?: number;
stopTimeoutMs?: number;
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort;
@ -76,6 +79,7 @@ export interface OpenCodeReadinessBridgeCommandBody {
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
@ -276,6 +280,37 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
};
}
async sendOpenCodeTeamMessage(
input: OpenCodeSendMessageCommandBody
): Promise<OpenCodeSendMessageCommandData> {
const result = await this.bridge.execute<
OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData
>('opencode.sendMessage', input, {
cwd: input.projectPath,
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
});
if (result.ok) {
return result.data;
}
return {
accepted: false,
memberName: input.memberName,
diagnostics: [
{
code: result.error.kind,
severity: 'error',
message: `OpenCode message bridge failed: ${result.error.message}`,
},
...result.diagnostics.map((event) => ({
code: event.type,
severity: event.severity,
message: event.message,
})),
],
};
}
private async executeStateChangingCommand<TBody, TData>(
command: OpenCodeStateChangingTeamCommandName,
body: TBody,

View file

@ -6,6 +6,8 @@ import type {
OpenCodeLaunchTeamCommandData,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeReconcileTeamCommandBody,
OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData,
OpenCodeStopTeamCommandBody,
OpenCodeStopTeamCommandData,
OpenCodeTeamLaunchMode,
@ -37,6 +39,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
input: OpenCodeReconcileTeamCommandBody
): Promise<OpenCodeLaunchTeamCommandData>;
stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise<OpenCodeStopTeamCommandData>;
sendOpenCodeTeamMessage?(
input: OpenCodeSendMessageCommandBody
): Promise<OpenCodeSendMessageCommandData>;
}
export interface OpenCodeTeamRuntimeAdapterOptions {
@ -47,6 +52,25 @@ export interface OpenCodeTeamRuntimeAdapterOptions {
launchEnabled?: boolean;
}
export interface OpenCodeTeamRuntimeMessageInput {
runId?: string;
teamName: string;
laneId: string;
memberName: string;
cwd: string;
text: string;
messageId?: string;
}
export interface OpenCodeTeamRuntimeMessageResult {
ok: boolean;
providerId: 'opencode';
memberName: string;
sessionId?: string;
runtimePid?: number;
diagnostics: string[];
}
export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract';
const REQUIRED_READY_CHECKPOINTS = new Set([
@ -139,6 +163,15 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
}
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(input.expectedMembers);
if (memberValidationDiagnostics.length > 0) {
return blockedLaunchResult(
input,
'opencode_invalid_expected_members',
memberValidationDiagnostics
);
}
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
const prepared = await this.prepare(input);
if (!prepared.ok) {
@ -182,6 +215,26 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
}
async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(input.expectedMembers);
if (memberValidationDiagnostics.length > 0) {
return {
...blockedLaunchResult(
{
runId: input.runId,
teamName: input.teamName,
cwd: input.expectedMembers[0]?.cwd ?? '',
providerId: this.providerId,
skipPermissions: false,
expectedMembers: input.expectedMembers,
previousLaunchState: input.previousLaunchState,
},
'opencode_invalid_expected_members',
memberValidationDiagnostics
),
snapshot: input.previousLaunchState,
};
}
if (this.bridge.reconcileOpenCodeTeam) {
const projectPath =
input.expectedMembers[0]?.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
@ -263,6 +316,40 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
};
}
async sendMessageToMember(
input: OpenCodeTeamRuntimeMessageInput
): Promise<OpenCodeTeamRuntimeMessageResult> {
if (!this.bridge.sendOpenCodeTeamMessage) {
return {
ok: false,
providerId: this.providerId,
memberName: input.memberName,
diagnostics: ['OpenCode message bridge is not registered.'],
};
}
const data = await this.bridge.sendOpenCodeTeamMessage({
runId: input.runId,
laneId: input.laneId,
teamId: input.teamName,
teamName: input.teamName,
projectPath: input.cwd,
memberName: input.memberName,
text: input.text,
messageId: input.messageId,
agent: 'teammate',
});
return {
ok: data.accepted,
providerId: this.providerId,
memberName: input.memberName,
sessionId: data.sessionId,
runtimePid: data.runtimePid,
diagnostics: data.diagnostics.map((diagnostic) => diagnostic.message),
};
}
async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
if (this.bridge.stopOpenCodeTeam) {
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
@ -355,10 +442,21 @@ function mapOpenCodeLaunchDataToRuntimeResult(
checkpointNames.has(name)
);
const bridgeReady = data.teamLaunchState === 'ready';
const success = bridgeReady && readyCheckpointsPresent;
const missingExpectedMembers = input.expectedMembers
.map((member) => member.name)
.filter((memberName) => data.members[memberName] == null);
const unconfirmedExpectedMembers = input.expectedMembers
.map((member) => member.name)
.filter((memberName) => data.members[memberName]?.launchState !== 'confirmed_alive');
const anyExpectedMemberFailed = input.expectedMembers.some(
(member) => data.members[member.name]?.launchState === 'failed'
);
const allExpectedMembersConfirmed =
input.expectedMembers.length > 0 && unconfirmedExpectedMembers.length === 0;
const success = bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed;
const checkpointDiagnostic = success
? []
: bridgeReady
: bridgeReady && !readyCheckpointsPresent
? [
`OpenCode bridge reported ready without all required durable checkpoints: missing ${[
...REQUIRED_READY_CHECKPOINTS,
@ -367,6 +465,12 @@ function mapOpenCodeLaunchDataToRuntimeResult(
.join(', ')}`,
]
: [];
const incompleteReadyDiagnostic =
bridgeReady && readyCheckpointsPresent && !allExpectedMembersConfirmed
? [
`OpenCode bridge reported ready before all expected members were confirmed: pending ${unconfirmedExpectedMembers.join(', ')}`,
]
: [];
const members = Object.fromEntries(
input.expectedMembers.map((member) => {
@ -396,6 +500,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
(evidence) => `${evidence.kind} at ${evidence.observedAt}`
),
...checkpointDiagnostic,
...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []),
]
),
];
@ -407,17 +512,25 @@ function mapOpenCodeLaunchDataToRuntimeResult(
teamName: input.teamName,
launchPhase: success
? 'finished'
: data.teamLaunchState === 'launching'
: data.teamLaunchState === 'launching' || (bridgeReady && !anyExpectedMemberFailed)
? 'active'
: 'finished',
teamLaunchState: success
? 'clean_success'
: data.teamLaunchState === 'launching' || data.teamLaunchState === 'permission_blocked'
? 'partial_pending'
: 'partial_failure',
: anyExpectedMemberFailed || data.teamLaunchState === 'failed'
? 'partial_failure'
: data.teamLaunchState === 'launching' ||
data.teamLaunchState === 'permission_blocked' ||
bridgeReady
? 'partial_pending'
: 'partial_failure',
members,
warnings: [...prepareWarnings, ...data.warnings.map((warning) => warning.message)],
diagnostics: [...data.diagnostics.map(formatOpenCodeBridgeDiagnostic), ...checkpointDiagnostic],
diagnostics: [
...data.diagnostics.map(formatOpenCodeBridgeDiagnostic),
...checkpointDiagnostic,
...incompleteReadyDiagnostic,
],
};
}
@ -482,6 +595,24 @@ function buildMemberBootstrapPrompt(input: TeamRuntimeLaunchInput, memberName: s
return `Join team "${input.teamName}" as "${memberName}" and wait for app MCP task delivery.`;
}
function validateOpenCodeRuntimeMembers(
members: TeamRuntimeLaunchInput['expectedMembers']
): string[] {
if (members.length === 0) {
return ['OpenCode runtime adapter requires at least one expected OpenCode member.'];
}
return members.flatMap((member, index) => {
const name = member.name.trim() || `<index ${index}>`;
if (member.providerId === 'opencode') {
return [];
}
return [
`OpenCode runtime adapter received non-OpenCode member "${name}" with provider "${member.providerId}".`,
];
});
}
function formatOpenCodeBridgeDiagnostic(diagnostic: {
code: string;
severity: 'info' | 'warning' | 'error';
@ -496,6 +627,8 @@ function blockedLaunchResult(
diagnostics: string[],
warnings: string[] = []
): TeamRuntimeLaunchResult {
const hardFailureReason =
reason === 'unknown_error' && diagnostics[0]?.trim() ? diagnostics[0].trim() : reason;
const members = Object.fromEntries(
input.expectedMembers.map((member) => [
member.name,
@ -507,7 +640,7 @@ function blockedLaunchResult(
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: reason,
hardFailureReason,
diagnostics,
},
])

View file

@ -1,6 +1,8 @@
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
export type {
OpenCodeTeamLaunchMode,
OpenCodeTeamRuntimeMessageInput,
OpenCodeTeamRuntimeMessageResult,
OpenCodeTeamRuntimeAdapterOptions,
OpenCodeTeamRuntimeBridgePort,
} from './OpenCodeTeamRuntimeAdapter';

View file

@ -40,6 +40,10 @@ import {
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
hasUnresolvedMemberSpawnStatus,
MEMBER_SPAWN_STATUS_REFRESH_MS,
} from '@renderer/utils/memberSpawnStatusPolling';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
@ -374,27 +378,46 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
isTeamProvisioning: boolean;
isTeamAlive?: boolean;
}): null {
const { leadActivity, memberSpawnStatuses, fetchMemberSpawnStatuses } = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
}))
);
const { leadActivity, memberSpawnStatuses, memberSpawnSnapshot, fetchMemberSpawnStatuses } =
useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
}))
);
useEffect(() => {
const hasUnresolvedSpawn = hasUnresolvedMemberSpawnStatus(
memberSpawnStatuses,
memberSpawnSnapshot
);
const shouldFetchSpawnStatuses =
isTeamProvisioning ||
hasUnresolvedSpawn ||
(memberSpawnStatuses == null &&
(isTeamAlive === true || leadActivity === 'active' || leadActivity === 'idle'));
if (shouldFetchSpawnStatuses) {
void fetchMemberSpawnStatuses(teamName);
}
if (!isTeamProvisioning && !hasUnresolvedSpawn) {
return;
}
const interval = window.setInterval(() => {
void fetchMemberSpawnStatuses(teamName);
}, MEMBER_SPAWN_STATUS_REFRESH_MS);
return () => {
window.clearInterval(interval);
};
}, [
fetchMemberSpawnStatuses,
isTeamAlive,
isTeamProvisioning,
leadActivity,
memberSpawnSnapshot,
memberSpawnStatuses,
teamName,
]);

View file

@ -148,9 +148,15 @@ function areMemberSpawnStatusesEquivalent(
leftEntry.status !== rightEntry?.status ||
leftEntry.launchState !== rightEntry.launchState ||
leftEntry.error !== rightEntry.error ||
leftEntry.hardFailure !== rightEntry.hardFailure ||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
leftEntry.livenessSource !== rightEntry.livenessSource ||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive
leftEntry.runtimeAlive !== rightEntry.runtimeAlive ||
leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed ||
leftEntry.agentToolAccepted !== rightEntry.agentToolAccepted ||
(leftEntry.pendingPermissionRequestIds ?? []).join('\0') !==
(rightEntry.pendingPermissionRequestIds ?? []).join('\0')
) {
return false;
}
@ -327,7 +333,7 @@ export const MemberList = memo(function MemberList({
isRemoved ? undefined : runtimeEntry
)}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error}
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}

View file

@ -51,13 +51,53 @@ function getSpawnEntry(
return memberSpawnStatuses[memberName];
}
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean {
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
}
function shouldPreferSnapshotEntryOverLive(
liveEntry: MemberSpawnStatusEntry | undefined,
snapshotEntry: MemberSpawnStatusEntry | undefined,
snapshotUpdatedAt: string | undefined
): boolean {
if (!liveEntry || !snapshotEntry) {
return false;
}
if (!isFailedSpawnEntry(liveEntry) || isFailedSpawnEntry(snapshotEntry)) {
return false;
}
const liveUpdatedAtMs = parseStatusUpdatedAtMs(liveEntry.updatedAt);
const snapshotUpdatedAtMs =
parseStatusUpdatedAtMs(snapshotEntry.updatedAt) ?? parseStatusUpdatedAtMs(snapshotUpdatedAt);
return (
snapshotUpdatedAtMs != null &&
(liveUpdatedAtMs == null || snapshotUpdatedAtMs >= liveUpdatedAtMs)
);
}
function summarizeLiveLaunchJoinMilestones(params: {
teammateNames: readonly string[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
}): Omit<LaunchJoinMilestones, 'expectedTeammateCount'> & {
observedTeammateCount: number;
} {
const { teammateNames, memberSpawnStatuses } = params;
const {
teammateNames,
memberSpawnStatuses,
memberSpawnSnapshotStatuses,
memberSpawnSnapshotUpdatedAt,
} = params;
let heartbeatConfirmedCount = 0;
let processOnlyAliveCount = 0;
let pendingSpawnCount = 0;
@ -65,7 +105,15 @@ function summarizeLiveLaunchJoinMilestones(params: {
let observedTeammateCount = 0;
for (const memberName of teammateNames) {
const entry = getSpawnEntry(memberSpawnStatuses, memberName);
const liveEntry = getSpawnEntry(memberSpawnStatuses, memberName);
const snapshotEntry = memberSpawnSnapshotStatuses?.[memberName];
const entry = shouldPreferSnapshotEntryOverLive(
liveEntry,
snapshotEntry,
memberSpawnSnapshotUpdatedAt
)
? snapshotEntry
: liveEntry;
if (!entry) {
pendingSpawnCount += 1;
continue;
@ -111,7 +159,10 @@ export function getLaunchJoinMilestonesFromMembers({
}: {
members: readonly LaunchJoinMemberLike[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'> & {
memberSpawnSnapshot?: Pick<
MemberSpawnStatusesSnapshot,
'expectedMembers' | 'summary' | 'updatedAt'
> & {
statuses?: MemberSpawnStatusesSnapshot['statuses'];
};
}): LaunchJoinMilestones {
@ -140,6 +191,8 @@ export function getLaunchJoinMilestonesFromMembers({
const liveSummary = summarizeLiveLaunchJoinMilestones({
teammateNames,
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
if (snapshotSummary) {
@ -263,6 +316,10 @@ export function getDisplayStepIndex({
return 3;
}
if (failedSpawnCount > 0) {
return 2;
}
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {

View file

@ -55,6 +55,29 @@ function matchField(text: string, pattern: RegExp): string | undefined {
return value ? value : undefined;
}
function matchOverrideField(
text: string,
fieldName: 'Provider override' | 'Model override' | 'Effort override'
): string | undefined {
const fieldPattern = new RegExp(`${fieldName}(?: for this teammate)?:\\s*`, 'i');
const fieldMatch = fieldPattern.exec(text);
if (!fieldMatch) {
return undefined;
}
const rest = text.slice(fieldMatch.index + fieldMatch[0].length);
const nextOverrideMatch =
/\.\s+(?:Provider override|Model override|Effort override)(?: for this teammate)?:/i.exec(rest);
const newlineIndex = rest.indexOf('\n');
const stopCandidates = [
nextOverrideMatch?.index,
newlineIndex >= 0 ? newlineIndex : undefined,
].filter((index): index is number => typeof index === 'number' && index >= 0);
const end = stopCandidates.length > 0 ? Math.min(...stopCandidates) : rest.length;
const value = rest.slice(0, end).trim().replace(/\.$/, '').trim();
return value ? value : undefined;
}
function buildRuntimeSummary(
providerId: TeamProviderId | null,
model: string | undefined,
@ -63,7 +86,7 @@ function buildRuntimeSummary(
if (providerId) {
const providerLabel = getTeamProviderLabel(providerId) ?? 'Anthropic';
const modelLabel = model ? (getTeamModelLabel(model) ?? model) : 'Default';
const effortLabel = getTeamEffortLabel(effort);
const effortLabel = effort ? getTeamEffortLabel(effort) : undefined;
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(
providerId,
modelLabel
@ -117,11 +140,9 @@ export function getBootstrapPromptDisplay(
matchField(text, /^You are\s+([^,\n]+),/m) ??
(typeof message.to === 'string' ? message.to.trim() : undefined);
const teamName = matchField(text, /on team "([^"]+)"/);
const providerId = parseProviderId(
matchField(text, /Provider override(?: for this teammate)?:\s*([^\.\n]+)/i)
);
const model = matchField(text, /Model override(?: for this teammate)?:\s*([^\.\n]+)/i);
const effort = matchField(text, /Effort override(?: for this teammate)?:\s*([^\.\n]+)/i);
const providerId = parseProviderId(matchOverrideField(text, 'Provider override'));
const model = matchOverrideField(text, 'Model override');
const effort = matchOverrideField(text, 'Effort override');
const runtime = buildRuntimeSummary(providerId, model, effort);
const displayName = teammateName ? displayMemberName(teammateName) : 'teammate';
const summary = `Starting ${displayName}`;

View file

@ -48,17 +48,21 @@ export function resolveMemberRuntimeSummary(
): string | undefined {
const memberProviderBackendId = (member as ResolvedTeamMember & { providerBackendId?: string })
.providerBackendId;
const memberModel = member.model?.trim() || '';
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
const inferredMemberProvider =
inferTeamProviderIdFromModel(memberModel) ?? inferTeamProviderIdFromModel(runtimeModel);
const configuredProvider: TeamProviderId =
member.providerId ?? launchParams?.providerId ?? 'anthropic';
member.providerId ?? inferredMemberProvider ?? launchParams?.providerId ?? 'anthropic';
const memberProviderForInheritance = member.providerId ?? inferredMemberProvider;
const inheritsLeadRuntimeDefaults =
member.providerId == null ||
memberProviderForInheritance == null ||
launchParams?.providerId == null ||
member.providerId === launchParams.providerId;
memberProviderForInheritance === launchParams.providerId;
const configuredModel =
member.model?.trim() || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : '');
memberModel || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : '');
const configuredEffort =
member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined);
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
const configuredProviderBackendId =
memberProviderBackendId ??
(inheritsLeadRuntimeDefaults ? launchParams?.providerBackendId : undefined);

View file

@ -0,0 +1,29 @@
import type { MemberSpawnStatusEntry } from '@shared/types';
export const MEMBER_SPAWN_STATUS_REFRESH_MS = 2_500;
export function hasUnresolvedMemberSpawnStatus(
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined,
memberSpawnSnapshot:
| {
statuses?: Record<string, MemberSpawnStatusEntry>;
summary?: { pendingCount?: number };
}
| undefined
): boolean {
if ((memberSpawnSnapshot?.summary?.pendingCount ?? 0) > 0) {
return true;
}
const entries = [
...Object.values(memberSpawnStatuses ?? {}),
...Object.values(memberSpawnSnapshot?.statuses ?? {}),
];
return entries.some(
(entry) =>
entry.status === 'waiting' ||
entry.status === 'spawning' ||
entry.launchState === 'starting' ||
entry.launchState === 'runtime_pending_bootstrap' ||
entry.launchState === 'runtime_pending_permission'
);
}

View file

@ -32,9 +32,54 @@ interface FailedSpawnDetail {
reason: string | null;
}
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean {
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
}
function shouldPreferSnapshotEntryOverLive(params: {
liveEntry: MemberSpawnStatusEntry | undefined;
snapshotEntry: MemberSpawnStatusEntry | undefined;
snapshotUpdatedAt?: string;
}): boolean {
const { liveEntry, snapshotEntry, snapshotUpdatedAt } = params;
if (!liveEntry || !snapshotEntry) {
return false;
}
if (!isFailedSpawnEntry(liveEntry) || isFailedSpawnEntry(snapshotEntry)) {
return false;
}
const liveUpdatedAtMs = parseStatusUpdatedAtMs(liveEntry.updatedAt);
const snapshotUpdatedAtMs =
parseStatusUpdatedAtMs(snapshotEntry.updatedAt) ?? parseStatusUpdatedAtMs(snapshotUpdatedAt);
return (
snapshotUpdatedAtMs != null &&
(liveUpdatedAtMs == null || snapshotUpdatedAtMs >= liveUpdatedAtMs)
);
}
function getPreferredSpawnEntry(params: {
liveEntry: MemberSpawnStatusEntry | undefined;
snapshotEntry: MemberSpawnStatusEntry | undefined;
snapshotUpdatedAt?: string;
}): MemberSpawnStatusEntry | undefined {
return shouldPreferSnapshotEntryOverLive(params)
? params.snapshotEntry
: (params.liveEntry ?? params.snapshotEntry);
}
function countPermissionBlockedMembers(params: {
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
}): number {
const names = new Set<string>();
if (params.memberSpawnStatuses instanceof Map) {
@ -57,7 +102,11 @@ function countPermissionBlockedMembers(params: {
? params.memberSpawnStatuses.get(name)
: params.memberSpawnStatuses?.[name];
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
const entry = liveEntry ?? snapshotEntry;
const entry = getPreferredSpawnEntry({
liveEntry,
snapshotEntry,
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
if (!entry) {
continue;
}
@ -89,6 +138,7 @@ const ACTIVE_PROVISIONING_STATES = new Set([
function getFailedSpawnDetails(params: {
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
}): FailedSpawnDetail[] {
const names = new Set<string>();
if (params.memberSpawnStatuses instanceof Map) {
@ -115,7 +165,14 @@ function getFailedSpawnDetails(params: {
? params.memberSpawnStatuses.get(name)
: params.memberSpawnStatuses?.[name];
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
return [name, liveEntry ?? snapshotEntry] as const;
return [
name,
getPreferredSpawnEntry({
liveEntry,
snapshotEntry,
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
}),
] as const;
})
.filter(
([, entry]) => entry && (entry.launchState === 'failed_to_start' || entry.status === 'error')
@ -229,7 +286,10 @@ export function buildTeamProvisioningPresentation({
progress: TeamProvisioningProgress | null | undefined;
members: readonly ProvisioningMemberLike[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'> & {
memberSpawnSnapshot?: Pick<
MemberSpawnStatusesSnapshot,
'expectedMembers' | 'summary' | 'updatedAt'
> & {
statuses?: MemberSpawnStatusesSnapshot['statuses'];
};
}): TeamProvisioningPresentation | null {
@ -265,6 +325,7 @@ export function buildTeamProvisioningPresentation({
const failedSpawnDetails = getFailedSpawnDetails({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails);
const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails);
@ -275,6 +336,7 @@ export function buildTeamProvisioningPresentation({
const permissionBlockedCount = countPermissionBlockedMembers({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
@ -369,7 +431,6 @@ export function buildTeamProvisioningPresentation({
isReady: true,
isFailed: false,
canCancel: false,
currentStepIndex: hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX,
expectedTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
@ -394,6 +455,8 @@ export function buildTeamProvisioningPresentation({
compactDetail: readyCompactDetail,
compactTone:
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success',
currentStepIndex:
failedSpawnCount > 0 ? 2 : hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX,
};
}

View file

@ -935,6 +935,7 @@ export interface PersistedTeamLaunchMemberState {
hardFailure: boolean;
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
runtimePid?: number;
firstSpawnAcceptedAt?: string;
lastHeartbeatAt?: string;
lastRuntimeAliveAt?: string;

View file

@ -46,6 +46,7 @@ const liveDescribe =
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MIXED_RECOVERY === '1'
? describe
: describe.skip;
const liveMultiLaneIt = process.env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? it : it.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
@ -174,7 +175,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
240_000
);
it(
liveMultiLaneIt(
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
async () => {
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;

View file

@ -42,12 +42,13 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
await expect(adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))).resolves
.toMatchObject({
ok: true,
providerId: 'opencode',
modelId: null,
});
await expect(
adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))
).resolves.toMatchObject({
ok: true,
providerId: 'opencode',
modelId: null,
});
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
projectPath: '/repo',
@ -57,11 +58,38 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
});
});
it('surfaces unknown readiness failures with the concrete bridge diagnostic on launch', async () => {
const bridge = bridgePort(
readiness({
state: 'unknown_error',
launchAllowed: false,
diagnostics: [
'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out',
],
missing: ['OpenCode bridge command timed out'],
})
);
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
teamLaunchState: 'partial_failure',
members: {
alice: {
launchState: 'failed_to_start',
hardFailureReason:
'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out',
diagnostics: [
'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out',
'OpenCode bridge command timed out',
],
},
},
});
});
it('fails closed when launch mode is disabled', async () => {
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
const adapter = new OpenCodeTeamRuntimeAdapter(
bridge
);
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
ok: false,
@ -72,27 +100,81 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
});
it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => {
const launchOpenCodeTeam = vi.fn();
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
});
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
const result = await adapter.launch(
launchInput({
expectedMembers: [
{
name: 'bob',
providerId: 'codex',
model: 'gpt-5.4-mini',
cwd: '/repo',
},
],
})
);
expect(result.teamLaunchState).toBe('partial_failure');
expect(result.members.bob).toMatchObject({
launchState: 'failed_to_start',
hardFailure: true,
hardFailureReason: 'opencode_invalid_expected_members',
diagnostics: [
'OpenCode runtime adapter received non-OpenCode member "bob" with provider "codex".',
],
});
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
expect(launchOpenCodeTeam).not.toHaveBeenCalled();
});
it('rejects empty OpenCode rosters before readiness or launch bridge dispatch', async () => {
const launchOpenCodeTeam = vi.fn();
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
});
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
const result = await adapter.launch(launchInput({ expectedMembers: [] }));
expect(result.teamLaunchState).toBe('partial_failure');
expect(result.members).toEqual({});
expect(result.diagnostics).toEqual([
'OpenCode runtime adapter requires at least one expected OpenCode member.',
]);
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
expect(launchOpenCodeTeam).not.toHaveBeenCalled();
});
it('maps ready bridge launch data to successful runtime evidence only with required checkpoints', async () => {
const launchOpenCodeTeam = vi.fn(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: [],
}) satisfies OpenCodeLaunchTeamCommandData);
const launchOpenCodeTeam = vi.fn(
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: [],
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
@ -130,6 +212,70 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
);
});
it('does not mark the lane clean_success when ready bridge data omits an expected member', async () => {
const launchOpenCodeTeam = vi.fn(
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: [],
durableCheckpoints: [
{ name: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
{ name: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
{ name: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
],
manifestHighWatermark: null,
runtimeStoreManifestHighWatermark: null,
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
}),
{ launchMode: 'dogfood' }
);
const result = await adapter.launch({
...launchInput(),
expectedMembers: [
...launchInput().expectedMembers,
{
name: 'bob',
providerId: 'opencode',
model: 'openai/gpt-5.4-mini',
cwd: '/repo',
},
],
});
expect(result.teamLaunchState).toBe('partial_pending');
expect(result.launchPhase).toBe('active');
expect(result.members.alice?.launchState).toBe('confirmed_alive');
expect(result.members.bob).toMatchObject({
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
hardFailure: false,
});
expect(result.members.bob?.diagnostics).toContain(
'OpenCode bridge response did not include bob; keeping the member pending until lane state materializes.'
);
});
it('reconciles from existing persisted launch snapshot without treating OpenCode as truth', async () => {
const snapshot = launchSnapshot();
const adapter = new OpenCodeTeamRuntimeAdapter(
@ -162,24 +308,72 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
});
});
it('keeps missing bridge members pending while reconcile is still launching', async () => {
const reconcileOpenCodeTeam = vi.fn(async () => ({
runId: 'run-1',
teamLaunchState: 'launching',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'confirmed_alive',
model: 'openai/gpt-5.4-mini',
evidence: [{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }],
},
},
warnings: [],
it('sends direct teammate messages through the OpenCode message bridge', async () => {
const sendOpenCodeTeamMessage = vi.fn(async () => ({
accepted: true,
sessionId: 'oc-session-bob',
memberName: 'bob',
runtimePid: 456,
diagnostics: [],
durableCheckpoints: [],
manifestHighWatermark: null,
runtimeStoreManifestHighWatermark: null,
}) satisfies OpenCodeLaunchTeamCommandData);
}));
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
sendOpenCodeTeamMessage,
})
);
await expect(
adapter.sendMessageToMember({
runId: 'run-1',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
text: 'hello bob',
messageId: 'msg-1',
})
).resolves.toEqual({
ok: true,
providerId: 'opencode',
memberName: 'bob',
sessionId: 'oc-session-bob',
runtimePid: 456,
diagnostics: [],
});
expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith({
runId: 'run-1',
laneId: 'secondary:opencode:bob',
teamId: 'team-a',
teamName: 'team-a',
projectPath: '/repo',
memberName: 'bob',
text: 'hello bob',
messageId: 'msg-1',
agent: 'teammate',
});
});
it('keeps missing bridge members pending while reconcile is still launching', async () => {
const reconcileOpenCodeTeam = vi.fn(
async () =>
({
runId: 'run-1',
teamLaunchState: 'launching',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'confirmed_alive',
model: 'openai/gpt-5.4-mini',
evidence: [{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }],
},
},
warnings: [],
diagnostics: [],
durableCheckpoints: [],
manifestHighWatermark: null,
runtimeStoreManifestHighWatermark: null,
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
reconcileOpenCodeTeam,
@ -244,27 +438,30 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
});
it('maps permission-blocked bridge members to runtime_pending_permission instead of bootstrap pending', async () => {
const launchOpenCodeTeam = vi.fn(async () => ({
runId: 'run-1',
teamLaunchState: 'permission_blocked',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'permission_blocked',
pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'],
diagnostics: ['waiting for permission approval'],
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: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
],
},
},
warnings: [],
diagnostics: [],
}) satisfies OpenCodeLaunchTeamCommandData);
const launchOpenCodeTeam = vi.fn(
async () =>
({
runId: 'run-1',
teamLaunchState: 'permission_blocked',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'permission_blocked',
pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'],
diagnostics: ['waiting for permission approval'],
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: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
],
},
},
warnings: [],
diagnostics: [],
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
@ -305,27 +502,30 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
});
it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => {
const launchOpenCodeTeam = vi.fn(async () => ({
runId: 'run-1',
teamLaunchState: 'permission_blocked',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'permission_blocked',
pendingPermissionRequestIds: ['perm-1'],
diagnostics: ['waiting for permission approval'],
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: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
],
},
},
warnings: [],
diagnostics: [],
}) satisfies OpenCodeLaunchTeamCommandData);
const launchOpenCodeTeam = vi.fn(
async () =>
({
runId: 'run-1',
teamLaunchState: 'permission_blocked',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'permission_blocked',
pendingPermissionRequestIds: ['perm-1'],
diagnostics: ['waiting for permission approval'],
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: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
],
},
},
warnings: [],
diagnostics: [],
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({

File diff suppressed because it is too large Load diff

View file

@ -268,10 +268,7 @@ function writeBootstrapState(
);
}
function writeTeamMeta(
teamName: string,
overrides: Record<string, unknown> = {}
): void {
function writeTeamMeta(teamName: string, overrides: Record<string, unknown> = {}): void {
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
@ -1104,22 +1101,22 @@ describe('TeamProvisioningService', () => {
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
(svc as any).runs.set('run-1', run);
vi.mocked(pidusage).mockReset();
vi
.mocked(pidusage)
.mockImplementation(async (target: number | string | Array<number | string>) => {
vi.mocked(pidusage).mockImplementation(
async (target: number | string | Array<number | string>) => {
if (Array.isArray(target)) {
return {
'111': createPidusageStat(111, 123_000_000),
} as any;
}
if (target === 333) {
return createPidusageStat(333, 456_000_000) as any;
if (target === 333) {
return createPidusageStat(333, 456_000_000) as any;
}
if (target === 111) {
return createPidusageStat(111, 123_000_000) as any;
}
throw new Error(`Unexpected pidusage target: ${String(target)}`);
}
if (target === 111) {
return createPidusageStat(111, 123_000_000) as any;
}
throw new Error(`Unexpected pidusage target: ${String(target)}`);
});
);
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
@ -1134,6 +1131,78 @@ describe('TeamProvisioningService', () => {
rssBytes: 456_000_000,
});
});
it('shows RSS for persisted OpenCode secondary lane runtime pids after the launch run is gone', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
(svc as any).launchStateStore = {
read: vi.fn(async () =>
createPersistedLaunchSnapshot({
teamName: 'runtime-team',
expectedMembers: ['bob'],
launchPhase: 'finished',
members: {
bob: {
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
laneId: 'secondary:opencode:bob',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
runtimePid: 333,
lastEvaluatedAt: '2026-04-23T12:26:31.563Z',
},
},
updatedAt: '2026-04-23T12:26:31.563Z',
})
),
};
vi.mocked(pidusage).mockReset();
vi.mocked(pidusage).mockImplementation(
async (target: number | string | Array<number | string>) => {
if (Array.isArray(target)) {
return {
'333': createPidusageStat(333, 456_000_000),
} as any;
}
if (target === 333) {
return createPidusageStat(333, 456_000_000) as any;
}
throw new Error(`Unexpected pidusage target: ${String(target)}`);
}
);
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
expect(pidusage).toHaveBeenCalledWith([333], { maxage: 0 });
expect(snapshot.members.bob).toMatchObject({
memberName: 'bob',
alive: true,
restartable: false,
pid: 333,
providerId: 'opencode',
runtimeModel: 'opencode/minimax-m2.5-free',
rssBytes: 456_000_000,
});
});
});
describe('restartMember', () => {
@ -1349,7 +1418,7 @@ describe('TeamProvisioningService', () => {
expect(restartMessage).not.toContain('nemotron-3-super-free');
});
it('requires the OpenCode runtime adapter before restarting a secondary-lane teammate', async () => {
it('requires the OpenCode runtime adapter before restarting a secondary-lane teammate', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'mixed-team',
@ -1408,7 +1477,7 @@ describe('TeamProvisioningService', () => {
);
});
it('still allows restarting a primary-lane teammate when another mixed secondary lane exists', async () => {
it('still allows restarting a primary-lane teammate when another mixed secondary lane exists', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'mixed-team',
@ -2200,9 +2269,9 @@ describe('TeamProvisioningService', () => {
const launchSummary = (svc as any).getMemberLaunchSummary(run);
expect((svc as any).hasPendingLaunchMembers(run, launchSummary, null)).toBe(true);
expect((svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)).toBe(
'Finishing launch — 1 teammate awaiting permission approval'
);
expect(
(svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)
).toBe('Finishing launch — 1 teammate awaiting permission approval');
});
it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => {
@ -2482,6 +2551,75 @@ describe('TeamProvisioningService', () => {
);
});
it('delivers direct messages to OpenCode secondary lanes through the runtime adapter', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
runtimePid: 456,
diagnostics: [],
}));
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
]);
svc.setRuntimeAdapterRegistry(registry);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello bob',
messageId: 'msg-1',
})
).resolves.toEqual({
delivered: true,
diagnostics: [],
});
expect(sendMessageToMember).toHaveBeenCalledWith({
runId: 'run-1',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
text: 'hello bob',
messageId: 'msg-1',
});
});
it('marks an OpenCode secondary lane degraded when readiness fails before runtime materializes', async () => {
const teamName = 'mixed-prelaunch-failure';
const svc = new TeamProvisioningService();
@ -2786,9 +2924,9 @@ describe('TeamProvisioningService', () => {
});
expect(write).toHaveBeenCalledTimes(1);
const writtenSnapshot = (write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined)?.[1] as
| { members?: Record<string, unknown> }
| undefined;
const writtenSnapshot = (
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined
)?.[1] as { members?: Record<string, unknown> } | undefined;
expect(writtenSnapshot?.members?.bob).toMatchObject({
name: 'bob',
providerId: 'opencode',
@ -2866,9 +3004,9 @@ describe('TeamProvisioningService', () => {
});
expect(write).toHaveBeenCalledTimes(1);
const writtenSnapshot = (write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined)?.[1] as
| { expectedMembers?: string[] }
| undefined;
const writtenSnapshot = (
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined
)?.[1] as { expectedMembers?: string[] } | undefined;
expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']);
});
@ -2904,7 +3042,10 @@ describe('TeamProvisioningService', () => {
it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => {
const svc = new TeamProvisioningService();
const delivered = new Map<string, { kind: 'member_inbox'; teamName: string; memberName: string; messageId: string }>();
const delivered = new Map<
string,
{ kind: 'member_inbox'; teamName: string; memberName: string; messageId: string }
>();
(svc as any).aliveRunByTeam.set('mixed-team', 'lead-run');
(svc as any).runs.set('lead-run', {
@ -3267,7 +3408,9 @@ describe('TeamProvisioningService', () => {
await (svc as any).stopSingleMixedSecondaryRuntimeLane(run, lane, 'relaunch');
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, run.teamName)).resolves.toMatchObject({
await expect(
readOpenCodeRuntimeLaneIndex(tempTeamsBase, run.teamName)
).resolves.toMatchObject({
lanes: {},
});
await expect(
@ -4276,6 +4419,486 @@ describe('TeamProvisioningService', () => {
);
});
describe('safe app launch matrix', () => {
function createSafeLaunchService() {
const mcpConfigBuilder = {
writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')),
removeConfigFile: vi.fn(async () => {}),
};
const membersMetaStore = {
writeMembers: vi.fn(async () => {}),
getMeta: vi.fn(async () => null),
};
const teamMetaStore = {
writeMeta: vi.fn(async () => {}),
deleteMeta: vi.fn(async () => {}),
getMeta: vi.fn(async () => null),
};
const svc = new TeamProvisioningService(
undefined,
undefined,
membersMetaStore as any,
undefined,
mcpConfigBuilder as any,
teamMetaStore as any
);
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { CODEX_API_KEY: 'test' },
authSource: 'codex_runtime',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async () => false);
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).stopFilesystemMonitor = vi.fn();
(svc as any).startStallWatchdog = vi.fn();
(svc as any).stopStallWatchdog = vi.fn();
(svc as any).attachStdoutHandler = vi.fn();
(svc as any).attachStderrHandler = vi.fn();
(svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({
providerId: 'codex',
providerBackendId: 'codex-native',
selectedModel: 'gpt-5.4',
selectedModelKind: 'explicit',
resolvedLaunchModel: 'gpt-5.4',
catalogId: 'gpt-5.4',
catalogSource: 'test',
catalogFetchedAt: '2026-04-23T00:00:00.000Z',
selectedEffort: 'medium',
resolvedEffort: 'medium',
selectedFastMode: null,
resolvedFastMode: null,
fastResolutionReason: null,
}));
return { svc, mcpConfigBuilder, membersMetaStore, teamMetaStore };
}
function readBootstrapSpecFromSpawnArgs(spawnArgs: string[]) {
const specIdx = spawnArgs.indexOf('--team-bootstrap-spec');
expect(specIdx).toBeGreaterThanOrEqual(0);
return JSON.parse(fs.readFileSync(spawnArgs[specIdx + 1], 'utf8')) as {
mode: string;
team: { name: string; cwd: string };
members: Array<{
name: string;
provider?: string;
model?: string;
effort?: string;
role?: string;
}>;
};
}
it('starts a pure Codex team through the app createTeam path without a real CLI process', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const { svc, membersMetaStore } = createSafeLaunchService();
const progress: string[] = [];
const { runId } = await svc.createTeam(
{
teamName: 'safe-codex-only-launch',
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'low',
},
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
},
],
},
(event) => progress.push(event.state)
);
const spawnCall = vi.mocked(spawnCli).mock.calls[0];
expect(spawnCall?.[0]).toBe('/mock/claude');
expect(spawnCall?.[2]).toMatchObject({
cwd: tempClaudeRoot,
stdio: ['pipe', 'pipe', 'pipe'],
});
const spawnArgs = spawnCall?.[1] as string[];
expect(spawnArgs).toEqual(expect.arrayContaining(['--model', 'gpt-5.4', '--effort', 'medium']));
const bootstrapSpec = readBootstrapSpecFromSpawnArgs(spawnArgs);
expect(bootstrapSpec).toMatchObject({
mode: 'create',
team: { name: 'safe-codex-only-launch', cwd: tempClaudeRoot },
});
expect(bootstrapSpec.members).toEqual([
expect.objectContaining({
name: 'alice',
provider: 'codex',
model: 'gpt-5.4-mini',
effort: 'low',
role: 'Reviewer',
}),
expect.objectContaining({
name: 'bob',
provider: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
role: 'Developer',
}),
]);
const run = (svc as any).runs.get(runId);
expect(run.expectedMembers).toEqual(['alice', 'bob']);
expect(run.allEffectiveMembers.map((member: { name: string }) => member.name)).toEqual([
'alice',
'bob',
]);
expect(run.mixedSecondaryLanes).toEqual([]);
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
'safe-codex-only-launch',
expect.arrayContaining([
expect.objectContaining({ name: 'alice', providerId: 'codex' }),
expect.objectContaining({ name: 'bob', providerId: 'codex' }),
]),
expect.objectContaining({ providerBackendId: 'codex-native' })
);
expect(progress).toEqual(expect.arrayContaining(['validating', 'spawning', 'configuring']));
await svc.cancelProvisioning(runId);
});
it('routes a pure OpenCode team directly through the runtime adapter without spawning the CLI lane', async () => {
allowConsoleLogs();
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
const expectedMembers = input.expectedMembers as Array<{ name: string }>;
return {
runId: String(input.runId),
teamName: String(input.teamName),
launchPhase: 'finished',
teamLaunchState: 'clean_success',
leadSessionId: 'opencode-lead-session',
members: Object.fromEntries(
expectedMembers.map((member) => [
member.name,
{
memberName: member.name,
providerId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
diagnostics: [],
},
])
),
warnings: [],
diagnostics: [],
};
});
const { svc, membersMetaStore } = createSafeLaunchService();
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
])
);
const progress: string[] = [];
const { runId } = await svc.createTeam(
{
teamName: 'safe-opencode-only-launch',
cwd: tempClaudeRoot,
providerId: 'opencode',
providerBackendId: 'adapter',
model: 'big-pickle',
effort: 'medium',
members: [
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'minimax-m2.5-free',
},
{
name: 'tom',
role: 'Developer',
providerId: 'opencode',
model: 'nemotron-3-super-free',
},
],
},
(event) => progress.push(event.state)
);
expect(runId).toEqual(expect.any(String));
expect(spawnCli).not.toHaveBeenCalled();
expect(ClaudeBinaryResolver.resolve).not.toHaveBeenCalled();
expect(adapterLaunch).toHaveBeenCalledWith(
expect.objectContaining({
laneId: 'primary',
providerId: 'opencode',
model: 'big-pickle',
effort: 'medium',
cwd: tempClaudeRoot,
expectedMembers: [
expect.objectContaining({
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
}),
expect.objectContaining({
name: 'tom',
providerId: 'opencode',
model: 'nemotron-3-super-free',
}),
],
})
);
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
'safe-opencode-only-launch',
expect.arrayContaining([
expect.objectContaining({ name: 'bob', providerId: 'opencode' }),
expect.objectContaining({ name: 'tom', providerId: 'opencode' }),
]),
expect.objectContaining({ providerBackendId: 'adapter' })
);
const config = JSON.parse(
fs.readFileSync(path.join(tempTeamsBase, 'safe-opencode-only-launch', 'config.json'), 'utf8')
) as { members: Array<{ name: string; providerId?: string; model?: string }> };
expect(config.members).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'team-lead', providerId: 'opencode', model: 'big-pickle' }),
expect.objectContaining({
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
}),
expect.objectContaining({
name: 'tom',
providerId: 'opencode',
model: 'nemotron-3-super-free',
}),
])
);
const publicStatuses = await svc.getMemberSpawnStatuses('safe-opencode-only-launch');
expect(publicStatuses.statuses.bob).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(publicStatuses.statuses.tom).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(publicStatuses.teamLaunchState).toBe('clean_success');
expect(progress).toEqual(expect.arrayContaining(['validating', 'spawning', 'ready']));
});
it('keeps Codex in the primary CLI lane and starts OpenCode teammates as secondary runtime lanes', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
const expectedMembers = input.expectedMembers as Array<{ name: string }>;
const memberName = expectedMembers[0]?.name ?? 'unknown';
return {
runId: String(input.runId),
teamName: String(input.teamName),
launchPhase: 'finished',
teamLaunchState: 'clean_success',
members: {
[memberName]: {
memberName,
providerId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
diagnostics: [],
},
},
warnings: [],
diagnostics: [],
};
});
const adapterStop = vi.fn(async () => {});
const { svc, membersMetaStore } = createSafeLaunchService();
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: adapterStop,
} as any,
])
);
const { runId } = await svc.createTeam(
{
teamName: 'safe-mixed-codex-opencode-launch',
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'low',
},
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'minimax-m2.5-free',
},
{
name: 'tom',
role: 'Developer',
providerId: 'opencode',
model: 'nemotron-3-super-free',
},
],
},
() => {}
);
const spawnArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
const bootstrapSpec = readBootstrapSpecFromSpawnArgs(spawnArgs);
expect(bootstrapSpec.members).toEqual([
expect.objectContaining({
name: 'alice',
provider: 'codex',
model: 'gpt-5.4-mini',
}),
]);
const run = (svc as any).runs.get(runId);
expect(run.expectedMembers).toEqual(['alice']);
expect(run.effectiveMembers.map((member: { name: string }) => member.name)).toEqual([
'alice',
]);
expect(run.allEffectiveMembers.map((member: { name: string }) => member.name)).toEqual([
'alice',
'bob',
'tom',
]);
expect(run.mixedSecondaryLanes).toEqual([
expect.objectContaining({
laneId: 'secondary:opencode:bob',
state: 'queued',
member: expect.objectContaining({
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
}),
}),
expect.objectContaining({
laneId: 'secondary:opencode:tom',
state: 'queued',
member: expect.objectContaining({
name: 'tom',
providerId: 'opencode',
model: 'nemotron-3-super-free',
}),
}),
]);
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
'safe-mixed-codex-opencode-launch',
expect.arrayContaining([
expect.objectContaining({ name: 'alice', providerId: 'codex' }),
expect.objectContaining({ name: 'bob', providerId: 'opencode' }),
expect.objectContaining({ name: 'tom', providerId: 'opencode' }),
]),
expect.objectContaining({ providerBackendId: 'codex-native' })
);
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2));
expect(adapterLaunch).toHaveBeenCalledWith(
expect.objectContaining({
laneId: 'secondary:opencode:bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
cwd: tempClaudeRoot,
expectedMembers: [
expect.objectContaining({
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
}),
],
})
);
expect(adapterLaunch).toHaveBeenCalledWith(
expect.objectContaining({
laneId: 'secondary:opencode:tom',
providerId: 'opencode',
model: 'nemotron-3-super-free',
cwd: tempClaudeRoot,
expectedMembers: [
expect.objectContaining({
name: 'tom',
providerId: 'opencode',
model: 'nemotron-3-super-free',
}),
],
})
);
await vi.waitFor(() => {
expect(run.mixedSecondaryLanes).toEqual([
expect.objectContaining({
laneId: 'secondary:opencode:bob',
state: 'finished',
result: expect.objectContaining({ teamLaunchState: 'clean_success' }),
}),
expect.objectContaining({
laneId: 'secondary:opencode:tom',
state: 'finished',
result: expect.objectContaining({ teamLaunchState: 'clean_success' }),
}),
]);
});
const publicStatuses = await svc.getMemberSpawnStatuses('safe-mixed-codex-opencode-launch');
expect(publicStatuses.statuses.bob).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(publicStatuses.statuses.tom).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(publicStatuses.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob', 'tom']));
await svc.cancelProvisioning(runId);
});
});
it('removes generated MCP config when launchTeam spawn fails synchronously', async () => {
allowConsoleLogs();
const teamName = 'launch-cleanup-team';
@ -6681,6 +7304,107 @@ describe('TeamProvisioningService', () => {
});
});
it('reconciles stale persisted mixed pending OpenCode lanes instead of keeping them pending forever', async () => {
const teamName = 'signal-ops-7';
writeTeamMeta(teamName, {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
});
writeMembersMeta(teamName, [
{
name: 'alice',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
{
name: 'jack',
providerId: 'opencode',
model: 'opencode/ling-2.6-flash-free',
},
]);
writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['alice']);
writeBootstrapState(teamName, [{ name: 'alice', status: 'registered' }]);
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempTeamsBase,
teamName,
laneId: 'secondary:opencode:jack',
state: 'active',
});
fs.writeFileSync(
getTeamLaunchStatePath(teamName),
`${JSON.stringify(
{
version: 2,
teamName,
updatedAt: '2026-04-23T10:00:00.000Z',
expectedMembers: ['alice', 'jack'],
bootstrapExpectedMembers: ['alice'],
leadSessionId: 'lead-session',
launchPhase: 'finished',
members: {
alice: {
name: 'alice',
providerId: 'codex',
laneId: 'primary',
laneKind: 'primary',
laneOwnerProviderId: 'codex',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
lastEvaluatedAt: '2026-04-23T10:00:00.000Z',
},
jack: {
name: 'jack',
providerId: 'opencode',
model: 'opencode/ling-2.6-flash-free',
laneId: 'secondary:opencode:jack',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'starting',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
lastEvaluatedAt: '2026-04-23T10:00:00.000Z',
diagnostics: ['Launching through OpenCode secondary lane.'],
},
},
summary: {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
teamLaunchState: 'partial_pending',
},
null,
2
)}\n`,
'utf8'
);
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.teamLaunchState).toBe('partial_failure');
expect(result.statuses.jack).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
error: expect.stringContaining('no lane state exists on disk'),
});
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
lanes: {
'secondary:opencode:jack': {
state: 'degraded',
},
},
});
});
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);

View file

@ -337,6 +337,73 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
});
});
it('does not mark Members joining complete when launch finishes with failed teammates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.memberSpawnStatusesByTeam['northstar-core'] = {
alice: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
agentToolAccepted: true,
},
bob: {
status: 'error',
launchState: 'failed_to_start',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'OpenCode lane failed before bootstrap',
agentToolAccepted: false,
},
jack: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
agentToolAccepted: true,
},
} as Record<string, unknown>;
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice', 'bob', 'jack'],
statuses: {},
summary: {
confirmedCount: 2,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
source: 'merged',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' }));
await Promise.resolve();
});
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.getAttribute('data-current-step-index')).toBe('2');
expect(block?.getAttribute('data-loading')).toBe('false');
expect(block?.getAttribute('data-success-severity')).toBe('warning');
expect(block?.textContent).toContain('Launch finished with errors');
expect(block?.textContent).toContain('bob failed to start');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses info severity while runtimes are online but teammate contact is still pending', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {

View file

@ -0,0 +1,101 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types';
vi.mock('@renderer/components/team/members/MemberCard', () => ({
MemberCard: ({
member,
spawnError,
}: {
member: ResolvedTeamMember;
spawnError?: string;
}) => React.createElement('div', { 'data-testid': `member-${member.name}` }, spawnError ?? ''),
}));
import { MemberList } from '@renderer/components/team/members/MemberList';
const member: ResolvedTeamMember = {
name: 'bob',
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'blue',
agentType: 'developer',
role: 'Developer',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
removedAt: undefined,
};
function failedSpawnStatus(reason: string): MemberSpawnStatusEntry {
return {
status: 'error',
launchState: 'failed_to_start',
updatedAt: '2026-04-23T10:00:00.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: reason,
agentToolAccepted: false,
};
}
describe('MemberList spawn-status memoization', () => {
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe(): void {}
disconnect(): void {}
}
);
});
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
it('rerenders cards when only the hard failure reason changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const members = [member];
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: new Map([['bob', failedSpawnStatus('initial OpenCode failure')]]),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('initial OpenCode failure');
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: new Map([['bob', failedSpawnStatus('updated OpenCode failure')]]),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('updated OpenCode failure');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -49,4 +49,19 @@ Do NOT send acknowledgement-only messages such as "ready" or "online".`);
expect(display?.summary).toBe('Starting alice');
expect(getSanitizedInboxMessageText(message)).toContain('Startup instructions are hidden in the UI.');
});
it('keeps dotted model ids intact and does not show implicit default effort', () => {
const message = makeMessage(`You are alice, a reviewer on team "forge-labs" (forge-labs). Provider override: codex. Model override: gpt-5.4-mini.
The team has already been created and you are being attached as a persistent teammate.
Your FIRST action: call MCP tool member_briefing with:
{ teamName: "forge-labs", memberName: "alice" }
Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this bootstrap step.
If member_briefing fails, send one short natural-language message to "team-lead" with the exact error text.
After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.
Do NOT send acknowledgement-only messages such as "ready" or "online".`);
const display = getBootstrapPromptDisplay(message);
expect(display?.runtime).toBe('GPT-5.4 Mini');
});
});

View file

@ -169,4 +169,58 @@ describe('resolveMemberRuntimeSummary', () => {
)
).toBe('nemotron-3-super-free · via OpenCode');
});
it('infers OpenCode from an OpenCode model when member provider metadata is missing', () => {
const member = createMember({
providerId: undefined,
providerBackendId: undefined,
model: 'opencode/minimax-m2.5-free',
effort: undefined,
});
expect(
resolveMemberRuntimeSummary(
member,
{
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
},
undefined
)
).toBe('minimax-m2.5-free · via OpenCode');
});
it('appends memory for OpenCode side-lane runtime snapshots without adding Codex backend text', () => {
const member = createMember({
providerId: 'opencode',
providerBackendId: undefined,
model: 'opencode/minimax-m2.5-free',
effort: undefined,
});
expect(
resolveMemberRuntimeSummary(
member,
{
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
},
undefined,
{
memberName: 'alice',
alive: true,
restartable: false,
runtimeModel: 'opencode/minimax-m2.5-free',
rssBytes: 183.9 * 1024 * 1024,
updatedAt: '2026-04-18T18:00:00.000Z',
}
)
).toBe('minimax-m2.5-free · via OpenCode · 183.9 MB');
});
});

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { hasUnresolvedMemberSpawnStatus } from '@renderer/utils/memberSpawnStatusPolling';
describe('hasUnresolvedMemberSpawnStatus', () => {
it('continues polling while any launch member is still starting', () => {
expect(
hasUnresolvedMemberSpawnStatus(
{
bob: {
status: 'spawning',
launchState: 'starting',
updatedAt: '2026-04-23T10:00:00.000Z',
},
},
undefined
)
).toBe(true);
});
it('continues polling after ready while snapshot summary still has pending members', () => {
expect(
hasUnresolvedMemberSpawnStatus(
{
alice: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-23T10:00:00.000Z',
},
},
{
summary: {
pendingCount: 1,
},
}
)
).toBe(true);
});
it('stops polling when every member is terminal confirmed or failed', () => {
expect(
hasUnresolvedMemberSpawnStatus(
{
alice: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-23T10:00:00.000Z',
},
bob: {
status: 'error',
launchState: 'failed_to_start',
updatedAt: '2026-04-23T10:00:00.000Z',
},
},
{
summary: {
pendingCount: 0,
},
}
)
).toBe(false);
});
});

View file

@ -150,9 +150,12 @@ describe('buildTeamProvisioningPresentation', () => {
},
});
expect(presentation?.successMessage).toBe('Launch finished with errors - 1/1 teammates failed to start');
expect(presentation?.successMessage).toBe(
'Launch finished with errors - 1/1 teammates failed to start'
);
expect(presentation?.panelMessage).toContain('requested model is not available');
expect(presentation?.compactDetail).toBe('jack failed to start');
expect(presentation?.currentStepIndex).toBe(2);
});
it('keeps a generic failed teammate message when only persisted failure counts remain', () => {
@ -201,9 +204,93 @@ describe('buildTeamProvisioningPresentation', () => {
},
});
expect(presentation?.successMessage).toBe('Launch finished with errors - 1/1 teammates failed to start');
expect(presentation?.successMessage).toBe(
'Launch finished with errors - 1/1 teammates failed to start'
);
expect(presentation?.panelMessage).toBe('1 teammate failed to start');
expect(presentation?.compactDetail).toBe('1 teammate failed to start');
expect(presentation?.currentStepIndex).toBe(2);
});
it('keeps Members joining incomplete while active launch already has failed teammates', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {
runId: 'run-3c',
teamName: 'mixed-team',
state: 'finalizing',
startedAt: '2026-04-13T10:00:00.000Z',
updatedAt: '2026-04-13T10:00:08.000Z',
message: 'Finishing launch',
messageSeverity: undefined,
pid: 4321,
configReady: true,
cliLogsTail: '',
assistantOutput: '',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'alice',
agentType: 'reviewer',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
agentType: 'developer',
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
memberSpawnStatuses: {
alice: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-13T10:00:07.000Z',
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
agentToolAccepted: true,
},
bob: {
status: 'error',
launchState: 'failed_to_start',
updatedAt: '2026-04-13T10:00:07.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'OpenCode lane failed',
agentToolAccepted: false,
},
},
memberSpawnSnapshot: {
expectedMembers: ['alice', 'bob'],
summary: {
confirmedCount: 1,
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
},
},
});
expect(presentation?.currentStepIndex).toBe(2);
expect(presentation?.panelMessage).toContain('bob failed to start');
expect(presentation?.compactTone).toBe('warning');
});
it('prefers live member spawn statuses over a stale persisted launch summary', () => {
@ -269,6 +356,81 @@ describe('buildTeamProvisioningPresentation', () => {
expect(presentation?.panelMessage).toBe('1 teammate still joining');
});
it('does not let stale live failures override a newer persisted pending snapshot', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {
runId: 'run-4-stale-live-failure',
teamName: 'mixed-team',
state: 'ready',
startedAt: '2026-04-13T10:00:00.000Z',
updatedAt: '2026-04-13T10:00:10.000Z',
message: 'Launch completed',
messageSeverity: undefined,
pid: 4321,
cliLogsTail: '',
assistantOutput: '',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'jack',
agentType: 'developer',
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
memberSpawnStatuses: {
jack: {
status: 'error',
launchState: 'failed_to_start',
hardFailureReason: 'Teammate was never spawned during launch.',
updatedAt: '2026-04-13T10:00:05.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
agentToolAccepted: false,
},
},
memberSpawnSnapshot: {
expectedMembers: ['jack'],
updatedAt: '2026-04-13T10:00:09.000Z',
statuses: {
jack: {
status: 'waiting',
launchState: 'starting',
updatedAt: '2026-04-13T10:00:09.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: false,
},
},
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
},
});
expect(presentation?.successMessage).toBe('Finishing launch');
expect(presentation?.panelMessage).toBe('1 teammate still joining');
expect(presentation?.compactDetail).toBe('1 teammate still joining');
expect(presentation?.failedSpawnCount).toBe(0);
});
it('surfaces permission-blocked teammates as awaiting approval while launch is finishing', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {