fix(team): harden team launch and create flows

This commit is contained in:
777genius 2026-04-23 00:21:26 +03:00
parent 6211fd95ab
commit 2db49d694c
24 changed files with 1788 additions and 211 deletions

View file

@ -65,7 +65,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'starting',
hardFailure: false,
hardFailureReason: undefined,
});
expect(snapshot.members.bob.diagnostics).toContain(
'Queued for OpenCode secondary lane launch.'
);
expect(snapshot.summary).toEqual({
confirmedCount: 1,
pendingCount: 1,

View file

@ -64,6 +64,54 @@ describe('planTeamRuntimeLanes', () => {
});
});
it('allows a non-OpenCode lead with only OpenCode teammates and leaves the primary lane teammate roster empty', () => {
const result = planTeamRuntimeLanes({
leadProviderId: 'codex',
members: [
{ name: 'alice', providerId: 'opencode', model: 'big-pickle' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
{ name: 'tom', providerId: 'opencode', model: 'ling-2.6-flash-free' },
],
});
expect(result).toMatchObject({
ok: true,
plan: {
mode: 'mixed_opencode_side_lanes',
primaryMembers: [],
sideLanes: [
{
laneId: 'secondary:opencode:alice',
providerId: 'opencode',
member: expect.objectContaining({
name: 'alice',
providerId: 'opencode',
model: 'big-pickle',
}),
},
{
laneId: 'secondary:opencode:bob',
providerId: 'opencode',
member: expect.objectContaining({
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
}),
},
{
laneId: 'secondary:opencode:tom',
providerId: 'opencode',
member: expect.objectContaining({
name: 'tom',
providerId: 'opencode',
model: 'ling-2.6-flash-free',
}),
},
],
},
});
});
it('creates a secondary OpenCode lane for an Anthropic-led mixed team', () => {
const result = planTeamRuntimeLanes({
leadProviderId: 'anthropic',

View file

@ -163,9 +163,7 @@ function createSecondaryLaneMemberState(
const providerId =
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
const evidence = params.evidence;
const hardFailureReason =
evidence?.hardFailureReason ??
(!evidence && params.pendingReason ? params.pendingReason : undefined);
const hardFailureReason = evidence?.hardFailureReason;
const launchState =
evidence?.launchState ??
deriveMemberLaunchState({
@ -213,7 +211,11 @@ function createSecondaryLaneMemberState(
inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
}
: undefined,
diagnostics: evidence?.diagnostics?.length ? [...evidence.diagnostics] : undefined,
diagnostics: evidence?.diagnostics?.length
? [...evidence.diagnostics]
: !evidence && params.pendingReason
? [params.pendingReason]
: undefined,
};
base.diagnostics = base.diagnostics?.length ? base.diagnostics : buildDiagnostics(base);
return base;

View file

@ -1191,6 +1191,35 @@ interface MixedSecondaryRuntimeLaneState {
diagnostics: string[];
}
function createUnexpectedMixedSecondaryLaneFailureResult(input: {
runId: string;
teamName: string;
memberName: string;
message: string;
}): TeamRuntimeLaunchResult {
return {
runId: input.runId,
teamName: input.teamName,
launchPhase: 'finished',
teamLaunchState: 'partial_failure',
members: {
[input.memberName]: {
memberName: input.memberName,
providerId: 'opencode',
launchState: 'failed_to_start',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: input.message,
diagnostics: [input.message],
},
},
warnings: [],
diagnostics: [input.message],
};
}
type LeadActivityState = 'active' | 'idle' | 'offline';
type ProvisioningAuthSource =
@ -1270,6 +1299,7 @@ interface LiveTeamAgentRuntimeMetadata {
backendType?: TeamAgentRuntimeBackendType;
agentId?: string;
pid?: number;
metricsPid?: number;
model?: string;
tmuxPaneId?: string;
}
@ -3142,11 +3172,12 @@ function emitLogsProgress(run: ProvisioningRun): void {
function buildCliExitError(code: number | null, stdoutText: string, stderrText: string): string {
const trimmed = buildCombinedLogs(stdoutText, stderrText).trim();
const cliCommandLabel = getConfiguredCliCommandLabel();
if (trimmed.length > 0) {
if (trimmed.toLowerCase().includes('please run /login')) {
return (
'Claude CLI reports it is not authenticated ("Please run /login"). ' +
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate, then retry. ' +
`${cliCommandLabel} reports it is not authenticated ("Please run /login"). ` +
'Run the CLI in a normal terminal and complete login, then retry. ' +
'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.'
);
}
@ -3154,10 +3185,10 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
}
if (code === 1) {
return 'Claude CLI exited with code 1. Typical causes: missing auth/onboarding for CLI, or command requiring interactive TTY. Run `claude` in a normal terminal, complete setup, and retry.';
return `${cliCommandLabel} exited with code 1 without stdout/stderr. Typical causes: missing auth/onboarding, interactive TTY requirements, or an early bootstrap/runtime crash. Check \`~/.claude/debug/latest\` for the real stack and retry.`;
}
return `Claude CLI exited with code ${code ?? 'unknown'}`;
return `${cliCommandLabel} exited with code ${code ?? 'unknown'}`;
}
interface CachedProbeResult {
@ -6167,7 +6198,7 @@ export class TeamProvisioningService {
runtimePids.add(leadPid);
}
for (const metadata of liveRuntimeByMember.values()) {
const memberPid = metadata.pid;
const memberPid = metadata.pid ?? metadata.metricsPid;
if (typeof memberPid === 'number' && Number.isFinite(memberPid) && memberPid > 0) {
runtimePids.add(memberPid);
}
@ -6260,12 +6291,38 @@ export class TeamProvisioningService {
persistedRuntimeMember?.backendType,
false
);
const restartable = backendType !== 'in-process';
const rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid;
const isOpenCodeMember =
(launchMember?.providerId ?? normalizeOptionalTeamProviderId(member.providerId)) ===
'opencode';
const isSharedOpenCodeHost =
isOpenCodeMember &&
!liveRuntimeMember?.pid &&
typeof liveRuntimeMember?.metricsPid === 'number' &&
liveRuntimeMember.metricsPid > 0;
const displayPid = liveRuntimeMember?.pid ?? (isSharedOpenCodeHost ? rssPid : undefined);
const restartable = isOpenCodeMember
? Boolean(liveRuntimeMember?.pid)
: isSharedOpenCodeHost
? false
: backendType !== 'in-process';
const runtimeModel =
liveRuntimeMember?.model ??
launchMember?.model?.trim() ??
member.model?.trim() ??
undefined;
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
try {
const refreshedStat = await pidusage(rssPid, { maxage: 0 });
if (Number.isFinite(refreshedStat.memory) && refreshedStat.memory >= 0) {
rssBytesByPid.set(rssPid, refreshedStat.memory);
rssBytes = refreshedStat.memory;
}
} catch {
// Shared OpenCode host can exit between discovery and the targeted RSS refresh.
}
}
snapshotMembers[memberName] = {
memberName,
@ -6278,11 +6335,9 @@ export class TeamProvisioningService {
: {}),
...(launchMember?.laneId ? { laneId: launchMember.laneId } : {}),
...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}),
...(liveRuntimeMember?.pid ? { pid: liveRuntimeMember.pid } : {}),
...(displayPid ? { pid: displayPid } : {}),
...(runtimeModel ? { runtimeModel } : {}),
...(liveRuntimeMember?.pid && rssBytesByPid.has(liveRuntimeMember.pid)
? { rssBytes: rssBytesByPid.get(liveRuntimeMember.pid) }
: {}),
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
updatedAt,
};
}
@ -11474,12 +11529,32 @@ export class TeamProvisioningService {
) {
continue;
}
const configuredRuntimeMember = member as unknown as Record<string, unknown>;
const configuredAgentId =
typeof configuredRuntimeMember.agentId === 'string'
? configuredRuntimeMember.agentId.trim()
: '';
const configuredTmuxPaneId =
typeof configuredRuntimeMember.tmuxPaneId === 'string'
? configuredRuntimeMember.tmuxPaneId.trim()
: '';
const configuredBackendType =
typeof configuredRuntimeMember.backendType === 'string'
? configuredRuntimeMember.backendType
: undefined;
const runtimeModel =
member.model?.trim() ||
this.findEffectiveRunMemberModel(run, memberName) ||
this.findMetaMemberModel(metaMembers, memberName);
upsertMetadata(memberName, {
...(runtimeModel ? { model: runtimeModel } : {}),
...(configuredAgentId ? { agentId: configuredAgentId } : {}),
...(configuredTmuxPaneId ? { tmuxPaneId: configuredTmuxPaneId } : {}),
...(normalizeTeamAgentRuntimeBackendType(configuredBackendType, false)
? {
backendType: normalizeTeamAgentRuntimeBackendType(configuredBackendType, false),
}
: {}),
});
}
@ -11514,6 +11589,23 @@ export class TeamProvisioningService {
});
}
for (const lane of run?.mixedSecondaryLanes ?? []) {
const memberName = lane.member.name?.trim() ?? '';
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
continue;
}
const evidence = lane.result?.members[memberName];
const runtimeModel = lane.member.model?.trim() || undefined;
upsertMetadata(memberName, {
backendType: 'process',
alive: evidence?.runtimeAlive === true || evidence?.agentToolAccepted === true,
...(runtimeModel ? { model: runtimeModel } : {}),
...(typeof evidence?.runtimePid === 'number' && evidence.runtimePid > 0
? { metricsPid: evidence.runtimePid }
: {}),
});
}
const paneIds = [...metadataByMember.values()]
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
.filter((paneId) => paneId.length > 0);
@ -11551,13 +11643,19 @@ export class TeamProvisioningService {
: undefined;
const status = this.findTrackedMemberSpawnStatus(run, memberName);
const mayInferAliveFromStatusOnly = status?.launchState !== 'failed_to_start';
const sharedRuntimeAlive =
backendType === 'process' &&
typeof metadata.metricsPid === 'number' &&
metadata.metricsPid > 0;
const alive =
typeof resolvedPid === 'number' && resolvedPid > 0
? true
: backendType === 'tmux'
? false
: mayInferAliveFromStatusOnly &&
Boolean(status?.runtimeAlive || status?.bootstrapConfirmed);
: sharedRuntimeAlive
? true
: mayInferAliveFromStatusOnly &&
Boolean(status?.runtimeAlive || status?.bootstrapConfirmed);
metadataByMember.set(memberName, {
...metadata,
alive,
@ -11866,7 +11964,7 @@ export class TeamProvisioningService {
launchPhase: PersistedTeamLaunchPhase
): PersistedTeamLaunchSnapshot | null {
const mixedSecondaryLanes = run.mixedSecondaryLanes ?? [];
if (!run.isLaunch || mixedSecondaryLanes.length === 0) {
if (mixedSecondaryLanes.length === 0) {
return null;
}
@ -12130,6 +12228,47 @@ export class TeamProvisioningService {
}
}
private launchQueuedMixedSecondaryLaneInBackground(
run: ProvisioningRun,
lane: MixedSecondaryRuntimeLaneState
): void {
if (lane.state !== 'queued') {
return;
}
lane.state = 'launching';
lane.runId = lane.runId ?? randomUUID();
void (async () => {
try {
await this.launchSingleMixedSecondaryLane(run, lane);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(
`[${run.teamName}] OpenCode secondary lane ${lane.laneId} crashed during launch orchestration: ${message}`
);
lane.state = 'finished';
lane.result = createUnexpectedMixedSecondaryLaneFailureResult({
runId: lane.runId ?? randomUUID(),
teamName: run.teamName,
memberName: lane.member.name,
message,
});
lane.warnings = [];
lane.diagnostics = [...lane.diagnostics, message];
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName: run.teamName,
laneId: lane.laneId,
state: 'degraded',
diagnostics: [message],
}).catch(() => undefined);
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
await this.publishMixedSecondaryLaneStatusChange(run, lane).catch(() => undefined);
}
})();
}
private async launchMixedSecondaryLaneIfNeeded(
run: ProvisioningRun
): Promise<PersistedTeamLaunchSnapshot | null> {
@ -12170,7 +12309,7 @@ export class TeamProvisioningService {
}
for (const lane of mixedSecondaryLanes) {
await this.launchSingleMixedSecondaryLane(run, lane);
this.launchQueuedMixedSecondaryLaneInBackground(run, lane);
}
return this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run));

View file

@ -64,6 +64,7 @@ export interface OpenCodeTeamMemberLaunchCommandData {
sessionId: string;
launchState: OpenCodeTeamMemberLaunchBridgeState;
model: string;
runtimePid?: number;
evidence: Array<{ kind: string; observedAt: string }>;
}

View file

@ -1,10 +1,16 @@
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import { mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises';
import * as path from 'path';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { createLogger } from '@shared/utils/logger';
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
import { withFileLock } from '../../fileLock';
import { createRuntimeStoreManifestStore } from './RuntimeStoreManifest';
const logger = createLogger('OpenCodeRuntimeManifestEvidenceReader');
export interface OpenCodeRuntimeManifestEvidenceReaderOptions {
teamsBasePath: string;
clock?: () => Date;
@ -29,6 +35,123 @@ export interface OpenCodeRuntimeLaneIndex {
lanes: Record<string, OpenCodeRuntimeLaneIndexEntry>;
}
function createEmptyOpenCodeRuntimeLaneIndex(
updatedAt = new Date().toISOString()
): OpenCodeRuntimeLaneIndex {
return {
version: 1,
updatedAt,
lanes: {},
};
}
function normalizeOpenCodeRuntimeLaneIndex(
parsed: Partial<OpenCodeRuntimeLaneIndex>,
fallbackUpdatedAt = new Date().toISOString()
): OpenCodeRuntimeLaneIndex {
if (
parsed.version !== 1 ||
typeof parsed.updatedAt !== 'string' ||
!parsed.lanes ||
typeof parsed.lanes !== 'object'
) {
return createEmptyOpenCodeRuntimeLaneIndex(fallbackUpdatedAt);
}
return {
version: 1,
updatedAt: parsed.updatedAt,
lanes: Object.fromEntries(
Object.entries(parsed.lanes).flatMap(([key, value]) => {
if (
!value ||
typeof value !== 'object' ||
typeof (value as OpenCodeRuntimeLaneIndexEntry).laneId !== 'string' ||
typeof (value as OpenCodeRuntimeLaneIndexEntry).updatedAt !== 'string'
) {
return [];
}
const entry = value as OpenCodeRuntimeLaneIndexEntry;
return [
[
key,
{
laneId: entry.laneId,
state:
entry.state === 'active' || entry.state === 'stopped' || entry.state === 'degraded'
? entry.state
: 'degraded',
updatedAt: entry.updatedAt,
diagnostics: Array.isArray(entry.diagnostics)
? entry.diagnostics.filter((item): item is string => typeof item === 'string')
: undefined,
} satisfies OpenCodeRuntimeLaneIndexEntry,
],
];
})
),
};
}
async function quarantineInvalidOpenCodeRuntimeLaneIndex(
filePath: string,
raw: string,
error: unknown
): Promise<void> {
const dir = path.dirname(filePath);
const quarantinePath = path.join(dir, `lanes.invalid.${Date.now()}.json`);
try {
await mkdir(dir, { recursive: true });
await atomicWriteAsync(quarantinePath, raw);
await rm(filePath, { force: true });
logger.warn(
`Quarantined invalid OpenCode lane index ${filePath} -> ${quarantinePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
} catch (quarantineError) {
logger.warn(
`Failed to quarantine invalid OpenCode lane index ${filePath}: ${
quarantineError instanceof Error ? quarantineError.message : String(quarantineError)
}`
);
}
}
async function readOpenCodeRuntimeLaneIndexUnlocked(
teamsBasePath: string,
teamName: string
): Promise<OpenCodeRuntimeLaneIndex> {
const filePath = getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName);
if (!(await fileExists(filePath))) {
return createEmptyOpenCodeRuntimeLaneIndex();
}
const raw = await readFile(filePath, 'utf8');
let parsed: Partial<OpenCodeRuntimeLaneIndex>;
try {
parsed = JSON.parse(raw) as Partial<OpenCodeRuntimeLaneIndex>;
} catch (error) {
await quarantineInvalidOpenCodeRuntimeLaneIndex(filePath, raw, error);
return createEmptyOpenCodeRuntimeLaneIndex();
}
return normalizeOpenCodeRuntimeLaneIndex(parsed);
}
async function writeOpenCodeRuntimeLaneIndexUnlocked(
teamsBasePath: string,
teamName: string,
index: OpenCodeRuntimeLaneIndex
): Promise<void> {
const runtimeDir = getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName);
await mkdir(runtimeDir, { recursive: true });
await atomicWriteAsync(
getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName),
`${JSON.stringify(index, null, 2)}\n`
);
}
export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManifestReader {
private readonly teamsBasePath: string;
private readonly clock: () => Date;
@ -200,61 +323,7 @@ export async function readOpenCodeRuntimeLaneIndex(
teamsBasePath: string,
teamName: string
): Promise<OpenCodeRuntimeLaneIndex> {
const filePath = getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName);
if (!(await fileExists(filePath))) {
return {
version: 1,
updatedAt: new Date().toISOString(),
lanes: {},
};
}
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<OpenCodeRuntimeLaneIndex>;
if (
parsed.version !== 1 ||
typeof parsed.updatedAt !== 'string' ||
!parsed.lanes ||
typeof parsed.lanes !== 'object'
) {
return {
version: 1,
updatedAt: new Date().toISOString(),
lanes: {},
};
}
return {
version: 1,
updatedAt: parsed.updatedAt,
lanes: Object.fromEntries(
Object.entries(parsed.lanes).flatMap(([key, value]) => {
if (
!value ||
typeof value !== 'object' ||
typeof (value as OpenCodeRuntimeLaneIndexEntry).laneId !== 'string' ||
typeof (value as OpenCodeRuntimeLaneIndexEntry).updatedAt !== 'string'
) {
return [];
}
const entry = value as OpenCodeRuntimeLaneIndexEntry;
return [
[
key,
{
laneId: entry.laneId,
state:
entry.state === 'active' || entry.state === 'stopped' || entry.state === 'degraded'
? entry.state
: 'degraded',
updatedAt: entry.updatedAt,
diagnostics: Array.isArray(entry.diagnostics)
? entry.diagnostics.filter((item): item is string => typeof item === 'string')
: undefined,
} satisfies OpenCodeRuntimeLaneIndexEntry,
],
];
})
),
};
return readOpenCodeRuntimeLaneIndexUnlocked(teamsBasePath, teamName);
}
export async function writeOpenCodeRuntimeLaneIndex(
@ -262,13 +331,10 @@ export async function writeOpenCodeRuntimeLaneIndex(
teamName: string,
index: OpenCodeRuntimeLaneIndex
): Promise<void> {
const runtimeDir = getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName);
await mkdir(runtimeDir, { recursive: true });
await writeFile(
getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName),
`${JSON.stringify(index, null, 2)}\n`,
'utf8'
);
const filePath = getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName);
await withFileLock(filePath, async () => {
await writeOpenCodeRuntimeLaneIndexUnlocked(teamsBasePath, teamName, index);
});
}
export async function upsertOpenCodeRuntimeLaneIndexEntry(params: {
@ -278,15 +344,18 @@ export async function upsertOpenCodeRuntimeLaneIndexEntry(params: {
state: OpenCodeRuntimeLaneIndexEntry['state'];
diagnostics?: string[];
}): Promise<void> {
const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName);
index.updatedAt = new Date().toISOString();
index.lanes[params.laneId] = {
laneId: params.laneId,
state: params.state,
updatedAt: index.updatedAt,
diagnostics: params.diagnostics?.length ? [...params.diagnostics] : undefined,
};
await writeOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName, index);
const filePath = getOpenCodeRuntimeLaneIndexPath(params.teamsBasePath, params.teamName);
await withFileLock(filePath, async () => {
const index = await readOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName);
index.updatedAt = new Date().toISOString();
index.lanes[params.laneId] = {
laneId: params.laneId,
state: params.state,
updatedAt: index.updatedAt,
diagnostics: params.diagnostics?.length ? [...params.diagnostics] : undefined,
};
await writeOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName, index);
});
}
export async function removeOpenCodeRuntimeLaneIndexEntry(params: {
@ -294,13 +363,16 @@ export async function removeOpenCodeRuntimeLaneIndexEntry(params: {
teamName: string;
laneId: string;
}): Promise<void> {
const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName);
if (!index.lanes[params.laneId]) {
return;
}
delete index.lanes[params.laneId];
index.updatedAt = new Date().toISOString();
await writeOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName, index);
const filePath = getOpenCodeRuntimeLaneIndexPath(params.teamsBasePath, params.teamName);
await withFileLock(filePath, async () => {
const index = await readOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName);
if (!index.lanes[params.laneId]) {
return;
}
delete index.lanes[params.laneId];
index.updatedAt = new Date().toISOString();
await writeOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName, index);
});
}
export async function clearOpenCodeRuntimeLaneStorage(params: {

View file

@ -377,6 +377,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
member.name,
bridgeMember?.launchState ?? 'failed',
bridgeMember?.sessionId,
bridgeMember?.runtimePid,
[
...(bridgeMember?.evidence ?? []).map(
(evidence) => `${evidence.kind} at ${evidence.observedAt}`
@ -411,6 +412,7 @@ function mapBridgeMemberToRuntimeEvidence(
memberName: string,
launchState: OpenCodeTeamMemberLaunchBridgeState,
sessionId: string | undefined,
runtimePid: number | undefined,
diagnostics: string[]
): TeamRuntimeMemberLaunchEvidence {
const confirmed = launchState === 'confirmed_alive';
@ -430,6 +432,9 @@ function mapBridgeMemberToRuntimeEvidence(
hardFailure: failed,
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
sessionId,
...(typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0
? { runtimePid }
: {}),
diagnostics,
};
}

View file

@ -71,6 +71,7 @@ export interface TeamRuntimeMemberLaunchEvidence {
hardFailureReason?: string;
sessionId?: string;
backendType?: TeamAgentRuntimeBackendType;
runtimePid?: number;
diagnostics: string[];
}

View file

@ -26,6 +26,7 @@ import {
normalizeProviderForMode,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button';
@ -50,12 +51,27 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import {
applyStoredCreateTeamMemberRuntimePreferences,
getStoredCreateTeamMemberRuntimePreferences,
getStoredCreateTeamEffort,
getStoredCreateTeamFastMode as getStoredTeamFastMode,
getStoredCreateTeamLimitContext,
getStoredCreateTeamModel as getStoredTeamModel,
getStoredCreateTeamProvider as getStoredTeamProvider,
getStoredCreateTeamSkipPermissions,
migrateLegacyCreateTeamPreferences,
setStoredCreateTeamMemberRuntimePreferences,
setStoredCreateTeamEffort,
setStoredCreateTeamFastMode,
setStoredCreateTeamLimitContext,
setStoredCreateTeamModel,
setStoredCreateTeamProvider,
setStoredCreateTeamSkipPermissions,
} from '@renderer/services/createTeamPreferences';
import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import {
isGeminiUiFrozen,
normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
@ -130,28 +146,6 @@ import type {
TeamProvisioningMemberInput,
} from '@shared/types';
function getStoredTeamProvider(): TeamProviderId {
const stored = localStorage.getItem('team:lastSelectedProvider');
// return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic';
return normalizeCreateLaunchProviderForUi(
stored === 'codex' || stored === 'gemini' ? stored : 'anthropic',
true
);
}
function getStoredTeamModel(providerId: TeamProviderId): string {
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
if (stored === null) {
return providerId === 'anthropic' ? 'opus' : '';
}
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
function getStoredTeamFastMode(): TeamFastMode {
const stored = localStorage.getItem('team:lastSelectedFastMode');
return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit';
}
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
const normalized = normalizePath(projectPath ?? '').toLowerCase();
return (
@ -422,16 +416,9 @@ export const CreateTeamDialog = ({
const [selectedModel, setSelectedModelRaw] = useState(() =>
getStoredTeamModel(getStoredTeamProvider())
);
const [limitContext, setLimitContextRaw] = useState(
() => localStorage.getItem('team:lastLimitContext') === 'true'
);
const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
);
const [selectedEffort, setSelectedEffortRaw] = useState(() => {
const stored = localStorage.getItem('team:lastSelectedEffort');
return stored === null ? 'medium' : stored;
});
const [limitContext, setLimitContextRaw] = useState(getStoredCreateTeamLimitContext);
const [skipPermissions, setSkipPermissionsRaw] = useState(getStoredCreateTeamSkipPermissions);
const [selectedEffort, setSelectedEffortRaw] = useState(getStoredCreateTeamEffort);
const [selectedFastMode, setSelectedFastModeRaw] = useState<TeamFastMode>(getStoredTeamFastMode);
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
@ -442,14 +429,7 @@ export const CreateTeamDialog = ({
const [customArgs, setCustomArgsRaw] = useState('');
useEffect(() => {
const legacyTeamModel = localStorage.getItem('team:lastSelectedModel');
if (
legacyTeamModel != null &&
localStorage.getItem('team:lastSelectedModel:anthropic') == null
) {
localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel);
}
localStorage.removeItem('team:lastSelectedModel');
migrateLegacyCreateTeamPreferences();
}, []);
// Re-read localStorage when advancedKey changes
@ -465,38 +445,38 @@ export const CreateTeamDialog = ({
const setSelectedModel = (value: string): void => {
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
setSelectedModelRaw(normalizedValue);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
setStoredCreateTeamModel(selectedProviderId, normalizedValue);
};
const setSelectedProviderId = (value: TeamProviderId): void => {
const normalizedValue = normalizeProviderForMode(value, multimodelEnabled);
setSelectedProviderIdRaw(normalizedValue);
localStorage.setItem('team:lastSelectedProvider', normalizedValue);
setStoredCreateTeamProvider(normalizedValue);
if (normalizedValue !== 'anthropic') {
setLimitContextRaw(false);
localStorage.setItem('team:lastLimitContext', 'false');
setStoredCreateTeamLimitContext(false);
}
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
};
const setLimitContext = (value: boolean): void => {
setLimitContextRaw(value);
localStorage.setItem('team:lastLimitContext', String(value));
setStoredCreateTeamLimitContext(value);
};
const setSkipPermissions = (value: boolean): void => {
setSkipPermissionsRaw(value);
localStorage.setItem('team:lastSkipPermissions', String(value));
setStoredCreateTeamSkipPermissions(value);
};
const setSelectedEffort = (value: string): void => {
setSelectedEffortRaw(value);
localStorage.setItem('team:lastSelectedEffort', value);
setStoredCreateTeamEffort(value);
};
const setSelectedFastMode = (value: TeamFastMode): void => {
setSelectedFastModeRaw(value);
localStorage.setItem('team:lastSelectedFastMode', value);
setStoredCreateTeamFastMode(value);
};
const setWorktreeEnabled = (value: boolean): void => {
@ -536,6 +516,13 @@ export const CreateTeamDialog = ({
resetUIState();
};
const persistCurrentMemberRuntimePreferences = useCallback(
(nextMembers: readonly MemberDraft[] = members): void => {
setStoredCreateTeamMemberRuntimePreferences(nextMembers);
},
[members]
);
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
/** All taken names: existing teams + teams currently being provisioned. */
@ -975,6 +962,9 @@ export const CreateTeamDialog = ({
}
if (initialData) {
const nextSyncModelsWithLead = !initialData.members.some(
(member) => member.providerId || member.model || member.effort
);
setTeamName(initialData.teamName);
descriptionDraft.setValue(initialData.description ?? '');
setTeamColor(initialData.color ?? '');
@ -1002,9 +992,7 @@ export const CreateTeamDialog = ({
initialData.members.length > 0 &&
initialData.members.every((member) => member.isolation === 'worktree')
);
setSyncModelsWithLead(
!initialData.members.some((member) => member.providerId || member.model || member.effort)
);
setSyncModelsWithLead(nextSyncModelsWithLead, { persistStoredPreference: false });
return;
}
@ -1012,18 +1000,35 @@ export const CreateTeamDialog = ({
return;
}
const nextDefaultMembers = DEFAULT_MEMBERS.map((member) =>
createMemberDraft({
name: member.name,
roleSelection: member.roleSelection,
workflow: member.workflow,
})
);
setMembers(
DEFAULT_MEMBERS.map((member) =>
createMemberDraft({
name: member.name,
roleSelection: member.roleSelection,
workflow: member.workflow,
})
)
syncModelsWithLead
? nextDefaultMembers
: applyStoredCreateTeamMemberRuntimePreferences(nextDefaultMembers)
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open/draftLoaded
}, [open, draftLoaded]);
useEffect(() => {
if (!open || !draftLoaded || initialData || syncModelsWithLead || members.length === 0) {
return;
}
persistCurrentMemberRuntimePreferences(members);
}, [
draftLoaded,
initialData,
members,
open,
persistCurrentMemberRuntimePreferences,
syncModelsWithLead,
]);
useEffect(() => {
if (!open || initialData || !draftLoaded) {
return;
@ -1221,14 +1226,14 @@ export const CreateTeamDialog = ({
const notices: string[] = [];
if (reconciliation.nextEffort !== selectedEffort) {
setSelectedEffortRaw(reconciliation.nextEffort);
localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort);
setStoredCreateTeamEffort(reconciliation.nextEffort);
if (reconciliation.effortResetReason) {
notices.push(reconciliation.effortResetReason);
}
}
if (reconciliation.nextFastMode !== selectedFastMode) {
setSelectedFastModeRaw(reconciliation.nextFastMode);
localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode);
setStoredCreateTeamFastMode(reconciliation.nextFastMode);
if (reconciliation.fastModeResetReason) {
notices.push(reconciliation.fastModeResetReason);
}
@ -1437,10 +1442,29 @@ export const CreateTeamDialog = ({
(checked: boolean): void => {
setSyncModelsWithLead(checked);
if (checked) {
persistCurrentMemberRuntimePreferences(members);
setMembers(members.map(clearMemberModelOverrides));
return;
}
if (getStoredCreateTeamMemberRuntimePreferences().length === 0) {
return;
}
const nextMembers = applyStoredCreateTeamMemberRuntimePreferences(members);
const hasRuntimeChanges = nextMembers.some((member, index) => {
const previousMember = members[index];
return (
member.providerId !== previousMember?.providerId ||
member.model !== previousMember?.model ||
member.effort !== previousMember?.effort
);
});
if (hasRuntimeChanges) {
setMembers(nextMembers);
}
},
[members, setMembers, setSyncModelsWithLead]
[members, persistCurrentMemberRuntimePreferences, setMembers, setSyncModelsWithLead]
);
const activeError =
@ -1496,6 +1520,9 @@ export const CreateTeamDialog = ({
if (!launchTeam) {
void (async () => {
try {
if (!syncModelsWithLead) {
persistCurrentMemberRuntimePreferences(members);
}
await api.teams.createConfig({
teamName: request.teamName,
displayName: request.displayName,
@ -1520,6 +1547,9 @@ export const CreateTeamDialog = ({
void (async () => {
try {
if (!syncModelsWithLead) {
persistCurrentMemberRuntimePreferences(members);
}
await onCreate(request);
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();

View file

@ -229,11 +229,7 @@ function getLocalTimezone(): string {
function getStoredTeamProvider(): TeamProviderId {
const stored = localStorage.getItem('team:lastSelectedProvider');
// return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic';
return normalizeCreateLaunchProviderForUi(
stored === 'codex' || stored === 'gemini' ? stored : 'anthropic',
true
);
return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true);
}
function getStoredTeamModel(providerId: TeamProviderId): string {
@ -753,12 +749,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
: [];
const editableMembersSource = filterEditableMemberInputs(nextMembersSource);
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
const savedProviderId =
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
? savedRequest.providerId
: savedRequest?.providerId === 'anthropic'
? 'anthropic'
: null;
const savedProviderId = normalizeOptionalTeamProviderId(savedRequest?.providerId) ?? null;
const savedProviderBackendId =
typeof savedRequest?.providerBackendId === 'string' &&
savedRequest.providerBackendId.trim().length > 0
@ -810,15 +801,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (!isLaunchMode) {
return null;
}
const fromLaunchParams = previousLaunchParams?.providerId;
if (
fromLaunchParams === 'anthropic' ||
fromLaunchParams === 'codex' ||
fromLaunchParams === 'gemini'
) {
return fromLaunchParams;
}
return savedLaunchProviderId;
return (
normalizeOptionalTeamProviderId(previousLaunchParams?.providerId) ?? savedLaunchProviderId
);
}, [isLaunchMode, previousLaunchParams?.providerId, savedLaunchProviderId]);
const providerChangeForcesFreshLeadContext = useMemo(() => {

View file

@ -124,6 +124,7 @@ export const MemberCard = ({
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
const presenceLabel = launchPresentation.presenceLabel;
const spawnCardClass = launchPresentation.cardClass;
const launchStatusLabel = launchPresentation.launchStatusLabel;
const colors = getTeamColorSet(memberColor);
const { isLight } = useTheme();
const pending = taskCounts?.pending ?? 0;
@ -146,11 +147,14 @@ export const MemberCard = ({
spawnLaunchState !== 'failed_to_start' &&
!activityTask &&
!runtimeSummary;
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
const showLaunchBadge =
!isRemoved && !activityTask && (presenceLabel === 'starting' || presenceLabel === 'connecting');
const launchBadgeLabel =
presenceLabel === 'starting' ? presenceLabel : (launchStatusLabel ?? presenceLabel);
const showRuntimeAdvisoryBadge =
!isRemoved &&
Boolean(runtimeAdvisoryLabel) &&
!showStartingBadge &&
!showLaunchBadge &&
spawnStatus !== 'error' &&
(Boolean(activityTask) || !isAwaitingReply);
@ -263,26 +267,19 @@ export const MemberCard = ({
</div>
) : null}
</div>
{showStartingBadge ? (
{showLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label="starting"
aria-label={presenceLabel}
/>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
starting
{launchBadgeLabel}
</Badge>
</span>
) : presenceLabel === 'connecting' ? (
!isRemoved ? (
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label="connecting"
/>
) : null
) : spawnStatus === 'error' ? (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -0,0 +1,185 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }),
}));
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'),
}));
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
[providerId, model || 'Default', effort].filter(Boolean).join(' · '),
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
getTeamProviderLabel: (providerId: string) => providerId,
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
}));
vi.mock('@renderer/components/team/RoleSelect', () => ({
RoleSelect: ({ value }: { value: string }) => React.createElement('div', null, value),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'aria-label'?: string;
}) =>
React.createElement(
'button',
{ type: 'button', onClick, disabled, 'aria-label': ariaLabel },
children
),
}));
vi.mock('@renderer/components/ui/checkbox', () => ({
Checkbox: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean;
onCheckedChange?: (value: boolean) => void;
}) =>
React.createElement('input', {
...props,
checked,
type: 'checkbox',
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onCheckedChange?.(event.target.checked),
}),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: ({
value,
onChange,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { value?: string }) =>
React.createElement('input', { ...props, value, onChange, type: 'text' }),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
React.createElement('label', props, children),
}));
vi.mock('@renderer/components/ui/MentionableTextarea', () => ({
MentionableTextarea: () => React.createElement('textarea'),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
useDraftPersistence: ({ initialValue }: { initialValue?: string }) => ({
value: initialValue ?? '',
setValue: () => undefined,
isSaved: true,
}),
}));
vi.mock('@renderer/hooks/useFileListCacheWarmer', () => ({
useFileListCacheWarmer: () => undefined,
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
import { createMemberDraft } from './membersEditorUtils';
import { MemberDraftRow } from './MemberDraftRow';
function renderMemberDraftRow(props: Partial<React.ComponentProps<typeof MemberDraftRow>> = {}): {
host: HTMLDivElement;
root: ReturnType<typeof createRoot>;
} {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(MemberDraftRow, {
member: createMemberDraft({
id: 'member-1',
name: 'alice',
roleSelection: 'developer',
providerId: 'anthropic',
model: 'opus',
}),
index: 0,
nameError: null,
onNameChange: () => undefined,
onRoleChange: () => undefined,
onCustomRoleChange: () => undefined,
onRemove: () => undefined,
onProviderChange: () => undefined,
onModelChange: () => undefined,
onEffortChange: () => undefined,
...props,
})
);
});
return { host, root };
}
describe('MemberDraftRow', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('does not show the sync tooltip copy when model controls are unlocked', () => {
const { host, root } = renderMemberDraftRow({
lockProviderModel: false,
forceInheritedModelSettings: false,
modelLockReason:
'This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort.',
});
expect(host.textContent).not.toContain('This teammate is synced with the lead model');
act(() => {
root.unmount();
});
});
it('shows inherited model copy when sync is enabled', () => {
const { host, root } = renderMemberDraftRow({
lockProviderModel: true,
forceInheritedModelSettings: true,
});
expect(host.textContent).toContain(
'Provider, model, and effort are inherited from the lead while sync is enabled.'
);
act(() => {
root.unmount();
});
});
});

View file

@ -215,7 +215,9 @@ export const MemberDraftRow = ({
const canOpenLockedModelPanel = lockProviderModel && !isRemoved && Boolean(lockedModelAction);
const modelTooltipText = forceInheritedModelSettings
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
: (lockedModelAction?.description ?? modelLockReason);
: lockProviderModel
? (lockedModelAction?.description ?? modelLockReason)
: undefined;
const hasModelIssue = Boolean(modelIssueText);
const runtimeSummary = formatTeamModelSummary(
effectiveProviderId,

View file

@ -0,0 +1,147 @@
import React, { act, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getStoredCreateTeamMemberRuntimePreferences } from '@renderer/services/createTeamPreferences';
const { loadSnapshotMock, saveSnapshotMock, deleteSnapshotMock } = vi.hoisted(() => ({
loadSnapshotMock: vi.fn(),
saveSnapshotMock: vi.fn(),
deleteSnapshotMock: vi.fn(),
}));
vi.mock('@renderer/services/createTeamDraftStorage', () => ({
createTeamDraftStorage: {
loadSnapshot: loadSnapshotMock,
saveSnapshot: saveSnapshotMock,
deleteSnapshot: deleteSnapshotMock,
},
}));
import { useCreateTeamDraft } from './useCreateTeamDraft';
function HookProbe({ onLoaded }: { onLoaded: () => void }): React.JSX.Element | null {
const draft = useCreateTeamDraft();
useEffect(() => {
if (draft.isLoaded) {
onLoaded();
}
}, [draft.isLoaded, onLoaded]);
return null;
}
function HookProbeWithDraft({
onLoaded,
}: {
onLoaded: (draft: ReturnType<typeof useCreateTeamDraft>) => void;
}): React.JSX.Element | null {
const draft = useCreateTeamDraft();
useEffect(() => {
if (draft.isLoaded) {
onLoaded(draft);
}
}, [draft, onLoaded]);
return null;
}
describe('useCreateTeamDraft', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
loadSnapshotMock.mockReset();
saveSnapshotMock.mockReset();
deleteSnapshotMock.mockReset();
localStorage.clear();
});
afterEach(() => {
document.body.innerHTML = '';
localStorage.clear();
});
it('migrates stored sync=false snapshots into create-team preferences', async () => {
loadSnapshotMock.mockResolvedValue({
version: 1,
teamName: 'team-alpha',
members: [
{
id: 'member-1',
name: 'alice',
roleSelection: 'developer',
customRole: '',
providerId: 'codex',
model: 'gpt-5',
effort: 'high',
},
],
syncModelsWithLead: false,
teammateWorktreeDefault: false,
cwdMode: 'project',
selectedProjectPath: '',
customCwd: '',
soloTeam: false,
launchTeam: true,
teamColor: '',
updatedAt: Date.now(),
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onLoaded = vi.fn();
await act(async () => {
root.render(React.createElement(HookProbe, { onLoaded }));
await Promise.resolve();
await Promise.resolve();
});
expect(onLoaded).toHaveBeenCalled();
expect(localStorage.getItem('createTeam:lastSyncModelsWithLead')).toBe('false');
expect(getStoredCreateTeamMemberRuntimePreferences()).toEqual([
{ name: 'alice', providerId: 'codex', model: 'gpt-5', effort: 'high' },
]);
act(() => {
root.unmount();
});
});
it('can update sync state without mutating the saved create-team default', async () => {
loadSnapshotMock.mockResolvedValue(null);
localStorage.setItem('createTeam:lastSyncModelsWithLead', 'true');
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
let loadedDraft: ReturnType<typeof useCreateTeamDraft> | null = null;
await act(async () => {
root.render(
React.createElement(HookProbeWithDraft, {
onLoaded: (draft) => {
loadedDraft = draft;
},
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(loadedDraft).not.toBeNull();
act(() => {
loadedDraft?.setSyncModelsWithLead(false, { persistStoredPreference: false });
});
expect(localStorage.getItem('createTeam:lastSyncModelsWithLead')).toBe('true');
act(() => {
root.unmount();
});
});
});

View file

@ -16,6 +16,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createMemberDraft } from '@renderer/components/team/members/membersEditorUtils';
import {
setStoredCreateTeamMemberRuntimePreferences,
getStoredCreateTeamSyncModelsWithLead,
setStoredCreateTeamSyncModelsWithLead,
} from '@renderer/services/createTeamPreferences';
import {
type CreateTeamDraftSnapshot,
createTeamDraftStorage,
@ -34,7 +39,7 @@ export interface UseCreateTeamDraftResult {
members: MemberDraft[];
setMembers: (v: MemberDraft[]) => void;
syncModelsWithLead: boolean;
setSyncModelsWithLead: (v: boolean) => void;
setSyncModelsWithLead: (v: boolean, options?: { persistStoredPreference?: boolean }) => void;
teammateWorktreeDefault: boolean;
setTeammateWorktreeDefault: (v: boolean) => void;
cwdMode: 'project' | 'custom';
@ -103,10 +108,12 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
// ---------------------------------------------------------------------------
export function useCreateTeamDraft(): UseCreateTeamDraftResult {
const storedSyncModelsWithLead = getStoredCreateTeamSyncModelsWithLead();
// ── State ──────────────────────────────────────────────────────────────
const [teamName, setTeamNameState] = useState('');
const [members, setMembersState] = useState<MemberDraft[]>([]);
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true);
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(storedSyncModelsWithLead);
const [teammateWorktreeDefault, setTeammateWorktreeDefaultState] = useState(false);
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
@ -119,7 +126,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
// ── Refs (latest values for debounced callbacks) ───────────────────────
const teamNameRef = useRef('');
const membersRef = useRef<MemberDraft[]>([]);
const syncModelsWithLeadRef = useRef(true);
const syncModelsWithLeadRef = useRef(storedSyncModelsWithLead);
const teammateWorktreeDefaultRef = useRef(false);
const cwdModeRef = useRef<'project' | 'custom'>('project');
const selectedProjectPathRef = useRef('');
@ -204,10 +211,17 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
const applySnapshot = useCallback((snap: CreateTeamDraftSnapshot) => {
const deserialized = deserializeMembers(snap.members);
const nextSyncModelsWithLead =
snap.syncModelsWithLead ?? getStoredCreateTeamSyncModelsWithLead();
setStoredCreateTeamSyncModelsWithLead(nextSyncModelsWithLead);
if (!nextSyncModelsWithLead) {
setStoredCreateTeamMemberRuntimePreferences(deserialized);
}
teamNameRef.current = snap.teamName;
membersRef.current = deserialized;
syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true;
syncModelsWithLeadRef.current = nextSyncModelsWithLead;
teammateWorktreeDefaultRef.current = snap.teammateWorktreeDefault === true;
cwdModeRef.current = snap.cwdMode;
selectedProjectPathRef.current = snap.selectedProjectPath;
@ -218,7 +232,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState(snap.teamName);
setMembersState(deserialized);
setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true);
setSyncModelsWithLeadState(nextSyncModelsWithLead);
setTeammateWorktreeDefaultState(snap.teammateWorktreeDefault === true);
setCwdModeState(snap.cwdMode);
setSelectedProjectPathState(snap.selectedProjectPath);
@ -285,10 +299,13 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
);
const setSyncModelsWithLead = useCallback(
(v: boolean) => {
(v: boolean, options?: { persistStoredPreference?: boolean }) => {
userTouchedRef.current = true;
syncModelsWithLeadRef.current = v;
setSyncModelsWithLeadState(v);
if (options?.persistStoredPreference !== false) {
setStoredCreateTeamSyncModelsWithLead(v);
}
scheduleSave();
},
[scheduleSave]
@ -367,6 +384,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
// ── Clear all ──────────────────────────────────────────────────────────
const clearDraft = useCallback(() => {
const nextStoredSyncModelsWithLead = getStoredCreateTeamSyncModelsWithLead();
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
@ -378,7 +396,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamNameRef.current = '';
membersRef.current = [];
syncModelsWithLeadRef.current = true;
syncModelsWithLeadRef.current = nextStoredSyncModelsWithLead;
teammateWorktreeDefaultRef.current = false;
cwdModeRef.current = 'project';
selectedProjectPathRef.current = '';
@ -389,7 +407,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState('');
setMembersState([]);
setSyncModelsWithLeadState(true);
setSyncModelsWithLeadState(nextStoredSyncModelsWithLead);
setTeammateWorktreeDefaultState(false);
setCwdModeState('project');
setSelectedProjectPathState('');

View file

@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
applyStoredCreateTeamMemberRuntimePreferences,
getStoredCreateTeamProvider,
getStoredCreateTeamMemberRuntimePreferences,
setStoredCreateTeamMemberRuntimePreferences,
} from '@renderer/services/createTeamPreferences';
describe('createTeamPreferences', () => {
afterEach(() => {
localStorage.clear();
});
it('stores teammate runtime preferences and reapplies them by member name', () => {
setStoredCreateTeamMemberRuntimePreferences([
{ name: 'alice', providerId: 'codex', model: 'gpt-5', effort: 'high' },
{ name: 'tom' },
]);
const restored = applyStoredCreateTeamMemberRuntimePreferences([
{ name: 'alice', providerId: undefined, model: '', effort: undefined },
{ name: 'tom', providerId: 'anthropic', model: 'opus', effort: 'medium' },
{ name: 'bob', providerId: undefined, model: '', effort: undefined },
]);
expect(getStoredCreateTeamMemberRuntimePreferences()).toEqual([
{ name: 'alice', providerId: 'codex', model: 'gpt-5', effort: 'high' },
{ name: 'tom', providerId: undefined, model: undefined, effort: undefined },
]);
expect(restored).toEqual([
{ name: 'alice', providerId: 'codex', model: 'gpt-5', effort: 'high' },
{ name: 'tom', providerId: undefined, model: '', effort: undefined },
{ name: 'bob', providerId: undefined, model: '', effort: undefined },
]);
});
it('merges teammate runtime preferences instead of dropping omitted members', () => {
setStoredCreateTeamMemberRuntimePreferences([
{ name: 'alice', providerId: 'codex', model: 'gpt-5', effort: 'high' },
{ name: 'bob', providerId: 'opencode', model: 'openai/gpt-5.4', effort: 'low' },
]);
setStoredCreateTeamMemberRuntimePreferences([
{ name: 'alice', providerId: 'anthropic', model: 'haiku', effort: 'medium' },
]);
expect(getStoredCreateTeamMemberRuntimePreferences()).toEqual([
{ name: 'alice', providerId: 'anthropic', model: 'haiku', effort: 'medium' },
{ name: 'bob', providerId: 'opencode', model: 'openai/gpt-5.4', effort: 'low' },
]);
});
it('keeps a stored OpenCode provider selection for future create-team runs', () => {
localStorage.setItem('createTeam:lastSelectedProvider', 'opencode');
expect(getStoredCreateTeamProvider()).toBe('opencode');
});
it('ignores invalid serialized preferences', () => {
localStorage.setItem(
'createTeam:lastMemberRuntimePreferences',
JSON.stringify({ version: 999, members: [{ name: 'alice', providerId: 'codex' }] })
);
expect(getStoredCreateTeamMemberRuntimePreferences()).toEqual([]);
});
});

View file

@ -0,0 +1,361 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
const CREATE_TEAM_PREFIX = 'createTeam:';
const LEGACY_TEAM_PREFIX = 'team:';
const CREATE_TEAM_PROVIDER_KEY = `${CREATE_TEAM_PREFIX}lastSelectedProvider`;
const CREATE_TEAM_FAST_MODE_KEY = `${CREATE_TEAM_PREFIX}lastSelectedFastMode`;
const CREATE_TEAM_LIMIT_CONTEXT_KEY = `${CREATE_TEAM_PREFIX}lastLimitContext`;
const CREATE_TEAM_SKIP_PERMISSIONS_KEY = `${CREATE_TEAM_PREFIX}lastSkipPermissions`;
const CREATE_TEAM_EFFORT_KEY = `${CREATE_TEAM_PREFIX}lastSelectedEffort`;
const CREATE_TEAM_SYNC_MODELS_KEY = `${CREATE_TEAM_PREFIX}lastSyncModelsWithLead`;
const CREATE_TEAM_MEMBER_RUNTIME_PREFERENCES_KEY = `${CREATE_TEAM_PREFIX}lastMemberRuntimePreferences`;
const CREATE_TEAM_MEMBER_RUNTIME_PREFERENCES_VERSION = 1;
export interface CreateTeamMemberRuntimePreference {
name: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
}
interface StoredCreateTeamMemberRuntimePreferences {
version: number;
members: CreateTeamMemberRuntimePreference[];
}
function readStorageItem(key: string): string | null {
try {
return globalThis.localStorage?.getItem(key) ?? null;
} catch {
return null;
}
}
function writeStorageItem(key: string, value: string): void {
try {
globalThis.localStorage?.setItem(key, value);
} catch {
// Ignore storage write failures in renderer helpers.
}
}
function removeStorageItem(key: string): void {
try {
globalThis.localStorage?.removeItem(key);
} catch {
// Ignore storage delete failures in renderer helpers.
}
}
function readCreateTeamPreference(key: string, legacyKey?: string): string | null {
const nextValue = readStorageItem(key);
if (nextValue != null) {
return nextValue;
}
return legacyKey ? readStorageItem(legacyKey) : null;
}
function isValidCreateTeamMemberRuntimePreference(
value: unknown
): value is CreateTeamMemberRuntimePreference {
if (typeof value !== 'object' || value === null) {
return false;
}
const entry = value as Record<string, unknown>;
return (
typeof entry.name === 'string' &&
(entry.providerId === undefined || normalizeOptionalTeamProviderId(entry.providerId) != null) &&
(entry.model === undefined || typeof entry.model === 'string') &&
(entry.effort === undefined || isTeamEffortLevel(entry.effort))
);
}
function parseStoredCreateTeamMemberRuntimePreferences(
value: string | null
): CreateTeamMemberRuntimePreference[] {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value) as StoredCreateTeamMemberRuntimePreferences;
if (
parsed.version !== CREATE_TEAM_MEMBER_RUNTIME_PREFERENCES_VERSION ||
!Array.isArray(parsed.members)
) {
return [];
}
return parsed.members.filter(isValidCreateTeamMemberRuntimePreference).map((entry) => {
const providerId = normalizeOptionalTeamProviderId(entry.providerId);
const normalizedModel = normalizeExplicitTeamModelForUi(providerId, entry.model ?? '');
return {
name: entry.name.trim(),
providerId,
model: normalizedModel || undefined,
effort: entry.effort,
};
});
} catch {
return [];
}
}
function normalizeCreateTeamMemberRuntimePreferences(
members: readonly {
name: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
removedAt?: number | string | null;
}[]
): CreateTeamMemberRuntimePreference[] {
const normalizedMembers: CreateTeamMemberRuntimePreference[] = [];
const seenNames = new Set<string>();
for (const member of members) {
if (member.removedAt) {
continue;
}
const name = member.name.trim();
const normalizedName = name.toLowerCase();
if (!name || seenNames.has(normalizedName)) {
continue;
}
seenNames.add(normalizedName);
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = normalizeExplicitTeamModelForUi(providerId, member.model ?? '');
normalizedMembers.push({
name,
providerId,
model: model || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
});
}
return normalizedMembers;
}
function getCreateTeamModelKey(providerId: TeamProviderId): string {
return `${CREATE_TEAM_PREFIX}lastSelectedModel:${providerId}`;
}
function getLegacyTeamModelKey(providerId: TeamProviderId): string {
return `${LEGACY_TEAM_PREFIX}lastSelectedModel:${providerId}`;
}
function copyLegacyPreferenceIfMissing(key: string, legacyKey: string): void {
if (readStorageItem(key) != null) {
return;
}
const legacyValue = readStorageItem(legacyKey);
if (legacyValue != null) {
writeStorageItem(key, legacyValue);
}
}
export function migrateLegacyCreateTeamPreferences(): void {
copyLegacyPreferenceIfMissing(
CREATE_TEAM_PROVIDER_KEY,
`${LEGACY_TEAM_PREFIX}lastSelectedProvider`
);
copyLegacyPreferenceIfMissing(
CREATE_TEAM_FAST_MODE_KEY,
`${LEGACY_TEAM_PREFIX}lastSelectedFastMode`
);
copyLegacyPreferenceIfMissing(
CREATE_TEAM_LIMIT_CONTEXT_KEY,
`${LEGACY_TEAM_PREFIX}lastLimitContext`
);
copyLegacyPreferenceIfMissing(
CREATE_TEAM_SKIP_PERMISSIONS_KEY,
`${LEGACY_TEAM_PREFIX}lastSkipPermissions`
);
copyLegacyPreferenceIfMissing(CREATE_TEAM_EFFORT_KEY, `${LEGACY_TEAM_PREFIX}lastSelectedEffort`);
for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) {
copyLegacyPreferenceIfMissing(
getCreateTeamModelKey(providerId),
getLegacyTeamModelKey(providerId)
);
}
const legacyTeamModel = readStorageItem(`${LEGACY_TEAM_PREFIX}lastSelectedModel`);
if (legacyTeamModel != null && readStorageItem(getCreateTeamModelKey('anthropic')) == null) {
writeStorageItem(getCreateTeamModelKey('anthropic'), legacyTeamModel);
}
removeStorageItem(`${LEGACY_TEAM_PREFIX}lastSelectedModel`);
}
export function getStoredCreateTeamProvider(): TeamProviderId {
const stored = readCreateTeamPreference(
CREATE_TEAM_PROVIDER_KEY,
`${LEGACY_TEAM_PREFIX}lastSelectedProvider`
);
return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true);
}
export function setStoredCreateTeamProvider(providerId: TeamProviderId): void {
writeStorageItem(CREATE_TEAM_PROVIDER_KEY, providerId);
}
export function getStoredCreateTeamModel(providerId: TeamProviderId): string {
const stored = readCreateTeamPreference(
getCreateTeamModelKey(providerId),
getLegacyTeamModelKey(providerId)
);
if (stored === null) {
return providerId === 'anthropic' ? 'opus' : '';
}
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
export function setStoredCreateTeamModel(providerId: TeamProviderId, model: string): void {
writeStorageItem(getCreateTeamModelKey(providerId), model);
}
export function getStoredCreateTeamFastMode(): TeamFastMode {
const stored = readCreateTeamPreference(
CREATE_TEAM_FAST_MODE_KEY,
`${LEGACY_TEAM_PREFIX}lastSelectedFastMode`
);
return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit';
}
export function setStoredCreateTeamFastMode(value: TeamFastMode): void {
writeStorageItem(CREATE_TEAM_FAST_MODE_KEY, value);
}
export function getStoredCreateTeamLimitContext(): boolean {
return (
readCreateTeamPreference(
CREATE_TEAM_LIMIT_CONTEXT_KEY,
`${LEGACY_TEAM_PREFIX}lastLimitContext`
) === 'true'
);
}
export function setStoredCreateTeamLimitContext(value: boolean): void {
writeStorageItem(CREATE_TEAM_LIMIT_CONTEXT_KEY, String(value));
}
export function getStoredCreateTeamSkipPermissions(): boolean {
return (
readCreateTeamPreference(
CREATE_TEAM_SKIP_PERMISSIONS_KEY,
`${LEGACY_TEAM_PREFIX}lastSkipPermissions`
) !== 'false'
);
}
export function setStoredCreateTeamSkipPermissions(value: boolean): void {
writeStorageItem(CREATE_TEAM_SKIP_PERMISSIONS_KEY, String(value));
}
export function getStoredCreateTeamEffort(): string {
return (
readCreateTeamPreference(CREATE_TEAM_EFFORT_KEY, `${LEGACY_TEAM_PREFIX}lastSelectedEffort`) ??
'medium'
);
}
export function setStoredCreateTeamEffort(value: string): void {
writeStorageItem(CREATE_TEAM_EFFORT_KEY, value);
}
export function getStoredCreateTeamSyncModelsWithLead(): boolean {
return readStorageItem(CREATE_TEAM_SYNC_MODELS_KEY) !== 'false';
}
export function setStoredCreateTeamSyncModelsWithLead(value: boolean): void {
writeStorageItem(CREATE_TEAM_SYNC_MODELS_KEY, String(value));
}
export function getStoredCreateTeamMemberRuntimePreferences(): CreateTeamMemberRuntimePreference[] {
return parseStoredCreateTeamMemberRuntimePreferences(
readStorageItem(CREATE_TEAM_MEMBER_RUNTIME_PREFERENCES_KEY)
);
}
export function setStoredCreateTeamMemberRuntimePreferences(
members: readonly {
name: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
removedAt?: number | string | null;
}[]
): void {
const existingMembers = getStoredCreateTeamMemberRuntimePreferences();
const nextMembers = normalizeCreateTeamMemberRuntimePreferences(members);
const mergedMembers = [...existingMembers];
const mergedIndexByName = new Map(
mergedMembers.map((member, index) => [member.name.trim().toLowerCase(), index] as const)
);
for (const member of nextMembers) {
const normalizedName = member.name.trim().toLowerCase();
const existingIndex = mergedIndexByName.get(normalizedName);
if (existingIndex == null) {
mergedIndexByName.set(normalizedName, mergedMembers.length);
mergedMembers.push(member);
continue;
}
mergedMembers[existingIndex] = member;
}
writeStorageItem(
CREATE_TEAM_MEMBER_RUNTIME_PREFERENCES_KEY,
JSON.stringify({
version: CREATE_TEAM_MEMBER_RUNTIME_PREFERENCES_VERSION,
members: mergedMembers,
} satisfies StoredCreateTeamMemberRuntimePreferences)
);
}
export function applyStoredCreateTeamMemberRuntimePreferences<
T extends {
name: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
removedAt?: number | string | null;
},
>(members: readonly T[]): T[] {
const storedPreferences = getStoredCreateTeamMemberRuntimePreferences();
if (storedPreferences.length === 0) {
return [...members];
}
const preferenceByName = new Map(
storedPreferences
.map((entry) => [entry.name.trim().toLowerCase(), entry] as const)
.filter(([name]) => name.length > 0)
);
return members.map((member) => {
if (member.removedAt) {
return member;
}
const preference = preferenceByName.get(member.name.trim().toLowerCase());
if (!preference) {
return member;
}
return {
...member,
providerId: preference.providerId,
model: preference.model ?? '',
effort: preference.effort,
};
});
}

View file

@ -1,5 +1,6 @@
import type { TeamProviderId } from '@shared/types';
import type { CliProviderId } from '@shared/types/cliInstaller';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
export const GEMINI_UI_FROZEN = true;
export const GEMINI_UI_DISABLED_REASON = 'Gemini in development';
@ -33,14 +34,11 @@ export function normalizeCreateLaunchProviderForUi(
return 'anthropic';
}
// return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
if (providerId === 'codex') {
return 'codex';
}
if (providerId === 'gemini' && GEMINI_UI_FROZEN) {
const normalizedProviderId = normalizeOptionalTeamProviderId(providerId);
if (normalizedProviderId === 'gemini' && GEMINI_UI_FROZEN) {
return 'anthropic';
}
return providerId === 'anthropic' ? 'anthropic' : 'anthropic';
return normalizedProviderId ?? 'anthropic';
}
export function isCreateLaunchProviderDisabled(

View file

@ -2,7 +2,7 @@ import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
OpenCodeRuntimeManifestEvidenceReader,
@ -289,4 +289,65 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
},
});
});
it('quarantines malformed lanes.json and falls back to an empty index', async () => {
const teamName = 'team-zeta';
const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName);
const filePath = getOpenCodeRuntimeLaneIndexPath(tempDir, teamName);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
await fs.mkdir(runtimeDir, { recursive: true });
await fs.writeFile(
filePath,
['{', ' "version": 1,', ' "updatedAt": "2026-04-22T10:00:00.000Z",', ' "lanes": {}', '}', '}'].join('\n'),
'utf8'
);
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toEqual({
version: 1,
updatedAt: expect.any(String),
lanes: {},
});
await expect(fs.readFile(filePath, 'utf8')).rejects.toThrow();
const runtimeEntries = await fs.readdir(runtimeDir);
expect(runtimeEntries.some((entry) => /^lanes\.invalid\.\d+\.json$/.test(entry))).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
it('serializes concurrent lane index upserts without losing sibling lanes', async () => {
const teamName = 'team-eta';
await Promise.all([
upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempDir,
teamName,
laneId: 'secondary:opencode:bob',
state: 'active',
}),
upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempDir,
teamName,
laneId: 'secondary:opencode:jack',
state: 'active',
}),
upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempDir,
teamName,
laneId: 'secondary:opencode:tom',
state: 'active',
}),
]);
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
lanes: {
'secondary:opencode:bob': { state: 'active' },
'secondary:opencode:jack': { state: 'active' },
'secondary:opencode:tom': { state: 'active' },
},
});
});
});

View file

@ -80,6 +80,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
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' },
@ -116,6 +117,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
providerId: 'opencode',
launchState: 'confirmed_alive',
sessionId: 'oc-session-1',
runtimePid: 123,
hardFailure: false,
},
},

View file

@ -840,6 +840,75 @@ describe('TeamProvisioningService', () => {
expect(metadata.has('alice')).toBe(false);
});
it('uses config runtime identity to detect live codex teammates when no persisted launch snapshot exists', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{
name: 'alice',
providerId: 'codex',
model: 'gpt-5.4-mini',
agentId: 'alice@signal-ops-6',
backendType: 'tmux',
tmuxPaneId: '%0',
},
{
name: 'atlas',
providerId: 'codex',
model: 'gpt-5.3-codex',
agentId: 'atlas@signal-ops-6',
backendType: 'tmux',
tmuxPaneId: '%1',
},
],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'alice',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
{
name: 'atlas',
providerId: 'codex',
model: 'gpt-5.3-codex',
},
]),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).findLiveProcessPidByAgentId = vi.fn(
() =>
new Map([
['alice@signal-ops-6', 17527],
['atlas@signal-ops-6', 17528],
])
);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('signal-ops-6');
expect(metadata.get('alice')).toMatchObject({
alive: true,
agentId: 'alice@signal-ops-6',
backendType: 'tmux',
tmuxPaneId: '%0',
pid: 17527,
model: 'gpt-5.4-mini',
});
expect(metadata.get('atlas')).toMatchObject({
alive: true,
agentId: 'atlas@signal-ops-6',
backendType: 'tmux',
tmuxPaneId: '%1',
pid: 17528,
model: 'gpt-5.3-codex',
});
});
it('does not let removed base member metadata hide an active suffixed member', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
@ -873,6 +942,114 @@ describe('TeamProvisioningService', () => {
});
expect(snapshot.members.alice).toBeUndefined();
});
it('shows RSS for OpenCode secondary lanes through the shared runtime host without exposing a member pid', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', providerId: 'codex', model: 'gpt-5.4-mini' },
],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
const run = createMemberSpawnRun({
runId: 'run-1',
teamName: 'runtime-team',
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
}),
],
]),
});
run.child = { pid: 111 };
run.request = { providerId: 'codex', model: 'gpt-5.4', members: [] };
run.mixedSecondaryLanes = [
{
laneId: 'secondary:opencode:bob',
providerId: 'opencode',
member: {
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
runId: 'secondary-run-1',
state: 'finished',
result: {
runId: 'secondary-run-1',
teamName: 'runtime-team',
launchPhase: 'active',
teamLaunchState: 'partial_pending',
members: {
bob: {
memberName: 'bob',
providerId: 'opencode',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
runtimePid: 333,
diagnostics: [],
},
},
warnings: [],
diagnostics: [],
},
warnings: [],
diagnostics: [],
},
];
(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>) => {
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 === 111) {
return createPidusageStat(111, 123_000_000) as any;
}
throw new Error(`Unexpected pidusage target: ${String(target)}`);
});
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 });
expect(pidusage).toHaveBeenCalledWith(333, { maxage: 0 });
expect(snapshot.members.bob).toMatchObject({
memberName: 'bob',
alive: true,
restartable: false,
pid: 333,
runtimeModel: 'opencode/minimax-m2.5-free',
rssBytes: 456_000_000,
});
});
});
describe('restartMember', () => {
@ -1839,8 +2016,9 @@ describe('TeamProvisioningService', () => {
];
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
expect(adapterLaunch).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(adapterLaunch).toHaveBeenCalledTimes(1);
});
expect(adapterLaunch).toHaveBeenCalledWith(
expect.objectContaining({
laneId: 'secondary:opencode:bob',
@ -1861,6 +2039,126 @@ describe('TeamProvisioningService', () => {
);
});
it('starts all queued OpenCode secondary lanes without letting the first in-flight lane block its siblings', async () => {
const svc = new TeamProvisioningService();
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
]);
svc.setRuntimeAdapterRegistry(registry);
const persistLaunchStateSnapshot = vi
.spyOn(svc as any, 'persistLaunchStateSnapshot')
.mockResolvedValue(null);
let resolveFirstLaunch: () => void = () => {};
const firstLaunch = new Promise<void>((resolve) => {
resolveFirstLaunch = resolve;
});
const launchSingleMixedSecondaryLane = vi
.spyOn(svc as any, 'launchSingleMixedSecondaryLane')
.mockImplementationOnce(async () => {
await firstLaunch;
})
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const run = createMemberSpawnRun({
teamName: 'mixed-team',
expectedMembers: ['alice'],
});
run.isLaunch = true;
run.request = {
teamName: 'mixed-team',
cwd: '/tmp/mixed-team',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
skipPermissions: true,
};
run.effectiveMembers = [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
},
];
run.mixedSecondaryLanes = [
{
laneId: 'secondary:opencode:tom',
providerId: 'opencode',
member: {
name: 'tom',
role: 'Developer',
providerId: 'opencode',
model: 'nemotron-3-super-free',
effort: 'medium',
},
runId: null,
state: 'queued',
result: null,
warnings: [],
diagnostics: [],
},
{
laneId: 'secondary:opencode:bob',
providerId: 'opencode',
member: {
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'minimax-m2.5-free',
effort: 'medium',
},
runId: null,
state: 'queued',
result: null,
warnings: [],
diagnostics: [],
},
{
laneId: 'secondary:opencode:jack',
providerId: 'opencode',
member: {
name: 'jack',
role: 'Developer',
providerId: 'opencode',
model: 'ling-2.6-flash-free',
effort: 'medium',
},
runId: null,
state: 'queued',
result: null,
warnings: [],
diagnostics: [],
},
];
const resultPromise = (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await Promise.resolve();
await Promise.resolve();
expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(3);
expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([
'launching',
'launching',
'launching',
]);
await expect(resultPromise).resolves.toBeNull();
expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(1);
resolveFirstLaunch();
await Promise.resolve();
});
it('preserves mixed lane metadata when OpenCode runtime liveness updates a secondary lane member', async () => {
const svc = new TeamProvisioningService();
const previousSnapshot = {
@ -5132,4 +5430,95 @@ describe('TeamProvisioningService', () => {
launchState: 'starting',
});
});
it('includes queued OpenCode secondary lanes in live spawn statuses during createTeam runs', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox').mockResolvedValue(undefined);
vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined);
const run = createMemberSpawnRun({
teamName: 'mixed-create-team',
runId: 'run-mixed-create-1',
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
livenessSource: 'heartbeat',
}),
],
]),
});
run.isLaunch = false;
run.request = {
teamName: 'mixed-create-team',
cwd: '/tmp/mixed-create-team',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
members: [],
};
run.effectiveMembers = [
{
name: 'alice',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
];
run.mixedSecondaryLanes = [
{
laneId: 'secondary:opencode:bob',
providerId: 'opencode',
member: {
name: 'bob',
providerId: 'opencode',
model: 'opencode/big-pickle',
},
runId: null,
state: 'queued',
result: null,
warnings: [],
diagnostics: [],
},
{
laneId: 'secondary:opencode:tom',
providerId: 'opencode',
member: {
name: 'tom',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
runId: null,
state: 'queued',
result: null,
warnings: [],
diagnostics: [],
},
];
run.detectedSessionId = 'lead-session';
(svc as any).runs.set(run.runId, run);
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
const result = await svc.getMemberSpawnStatuses(run.teamName);
expect(result.teamLaunchState).toBe('partial_pending');
expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob', 'tom']));
expect(result.statuses.alice).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
expect(result.statuses.bob).toMatchObject({
status: 'spawning',
launchState: 'starting',
});
expect(result.statuses.tom).toMatchObject({
status: 'spawning',
launchState: 'starting',
});
});
});

View file

@ -235,6 +235,36 @@ describe('resolveLaunchDialogPrefill', () => {
});
});
it('preserves OpenCode relaunch runtime instead of collapsing it to Anthropic', () => {
const result = resolveLaunchDialogPrefill({
members: [],
savedRequest: null,
previousLaunchParams: {
providerId: 'opencode',
model: 'openrouter/moonshotai/kimi-k2',
effort: 'medium',
},
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
opencode: 'openai/gpt-5.4',
}),
});
expect(result).toEqual({
providerId: 'opencode',
providerBackendId: undefined,
model: 'openrouter/moonshotai/kimi-k2',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});
it('prefers per-team launch params for limitContext over stale global storage', () => {
const result = resolveLaunchDialogPrefill({
members: [],

View file

@ -133,6 +133,33 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('shows a full loading badge for connecting teammates during provisioning', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
isTeamAlive: false,
isTeamProvisioning: true,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('connecting');
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps runtime retry visible even while the teammate already has an active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -26,4 +26,8 @@ describe('geminiUiFreeze', () => {
it('keeps codex available when multimodel is enabled', () => {
expect(normalizeCreateLaunchProviderForUi('codex', true)).toBe('codex');
});
it('keeps opencode available when multimodel is enabled', () => {
expect(normalizeCreateLaunchProviderForUi('opencode', true)).toBe('opencode');
});
});