diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts index c429f84d..b9b9fcfe 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts @@ -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, diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts index 5f810746..51704b72 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts @@ -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', diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 7b0afef7..56b2df2b 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -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; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 37ded340..6f5f4f06 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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; + 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 { @@ -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)); diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index a72f3b97..e8f2bb90 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -64,6 +64,7 @@ export interface OpenCodeTeamMemberLaunchCommandData { sessionId: string; launchState: OpenCodeTeamMemberLaunchBridgeState; model: string; + runtimePid?: number; evidence: Array<{ kind: string; observedAt: string }>; } diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index f638b273..b393ed1b 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -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; } +function createEmptyOpenCodeRuntimeLaneIndex( + updatedAt = new Date().toISOString() +): OpenCodeRuntimeLaneIndex { + return { + version: 1, + updatedAt, + lanes: {}, + }; +} + +function normalizeOpenCodeRuntimeLaneIndex( + parsed: Partial, + 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 { + 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 { + const filePath = getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName); + if (!(await fileExists(filePath))) { + return createEmptyOpenCodeRuntimeLaneIndex(); + } + const raw = await readFile(filePath, 'utf8'); + + let parsed: Partial; + try { + parsed = JSON.parse(raw) as Partial; + } catch (error) { + await quarantineInvalidOpenCodeRuntimeLaneIndex(filePath, raw, error); + return createEmptyOpenCodeRuntimeLaneIndex(); + } + + return normalizeOpenCodeRuntimeLaneIndex(parsed); +} + +async function writeOpenCodeRuntimeLaneIndexUnlocked( + teamsBasePath: string, + teamName: string, + index: OpenCodeRuntimeLaneIndex +): Promise { + 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 { - 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; - 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 { - 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 { - 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 { - 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: { diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 6686c89f..d13d5614 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -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, }; } diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index ad8a77d7..b66cf773 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -71,6 +71,7 @@ export interface TeamRuntimeMemberLaunchEvidence { hardFailureReason?: string; sessionId?: string; backendType?: TeamAgentRuntimeBackendType; + runtimePid?: number; diagnostics: string[]; } diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 0d0fe5db..701a58a2 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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(getStoredTeamFastMode); const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState(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(); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 7230d233..05536833 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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(() => { diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 23bba677..44b58cc5 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 = ({ ) : null} - {showStartingBadge ? ( + {showLaunchBadge ? ( - starting + {launchBadgeLabel} - ) : presenceLabel === 'connecting' ? ( - !isRemoved ? ( - - ) : null ) : spawnStatus === 'error' ? ( diff --git a/src/renderer/components/team/members/MemberDraftRow.test.tsx b/src/renderer/components/team/members/MemberDraftRow.test.tsx new file mode 100644 index 00000000..bd3e1f31 --- /dev/null +++ b/src/renderer/components/team/members/MemberDraftRow.test.tsx @@ -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; + 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) => + onCheckedChange?.(event.target.checked), + }), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: ({ + value, + onChange, + ...props + }: React.InputHTMLAttributes & { value?: string }) => + React.createElement('input', { ...props, value, onChange, type: 'text' }), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ + children, + ...props + }: React.LabelHTMLAttributes & { 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> = {}): { + host: HTMLDivElement; + root: ReturnType; +} { + 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(); + }); + }); +}); diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index c75b1784..b6e7b401 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -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, diff --git a/src/renderer/hooks/useCreateTeamDraft.test.tsx b/src/renderer/hooks/useCreateTeamDraft.test.tsx new file mode 100644 index 00000000..707a9e9a --- /dev/null +++ b/src/renderer/hooks/useCreateTeamDraft.test.tsx @@ -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) => 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 | 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(); + }); + }); +}); diff --git a/src/renderer/hooks/useCreateTeamDraft.ts b/src/renderer/hooks/useCreateTeamDraft.ts index f4f76830..a9818e77 100644 --- a/src/renderer/hooks/useCreateTeamDraft.ts +++ b/src/renderer/hooks/useCreateTeamDraft.ts @@ -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([]); - 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([]); - 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(''); diff --git a/src/renderer/services/__tests__/createTeamPreferences.test.ts b/src/renderer/services/__tests__/createTeamPreferences.test.ts new file mode 100644 index 00000000..d73a19fe --- /dev/null +++ b/src/renderer/services/__tests__/createTeamPreferences.test.ts @@ -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([]); + }); +}); diff --git a/src/renderer/services/createTeamPreferences.ts b/src/renderer/services/createTeamPreferences.ts new file mode 100644 index 00000000..be266289 --- /dev/null +++ b/src/renderer/services/createTeamPreferences.ts @@ -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; + 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(); + + 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, + }; + }); +} diff --git a/src/renderer/utils/geminiUiFreeze.ts b/src/renderer/utils/geminiUiFreeze.ts index e96e2bf2..8de95e31 100644 --- a/src/renderer/utils/geminiUiFreeze.ts +++ b/src/renderer/utils/geminiUiFreeze.ts @@ -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( diff --git a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts index 99a36fe1..574cf63d 100644 --- a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts +++ b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts @@ -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' }, + }, + }); + }); }); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 15e1befb..a6b3ffff 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -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, }, }, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7c5d4a35..a3012588 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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) => { + 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((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', + }); + }); }); diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts index 59664344..d632304f 100644 --- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -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: [], diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index b797cd0c..9fa1294d 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -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'); diff --git a/test/renderer/utils/geminiUiFreeze.test.ts b/test/renderer/utils/geminiUiFreeze.test.ts index e6e72d05..7fe9671b 100644 --- a/test/renderer/utils/geminiUiFreeze.test.ts +++ b/test/renderer/utils/geminiUiFreeze.test.ts @@ -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'); + }); });