fix(team): harden team launch and create flows
This commit is contained in:
parent
6211fd95ab
commit
2db49d694c
24 changed files with 1788 additions and 211 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export interface OpenCodeTeamMemberLaunchCommandData {
|
|||
sessionId: string;
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState;
|
||||
model: string;
|
||||
runtimePid?: number;
|
||||
evidence: Array<{ kind: string; observedAt: string }>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
|||
hardFailureReason?: string;
|
||||
sessionId?: string;
|
||||
backendType?: TeamAgentRuntimeBackendType;
|
||||
runtimePid?: number;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
185
src/renderer/components/team/members/MemberDraftRow.test.tsx
Normal file
185
src/renderer/components/team/members/MemberDraftRow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
147
src/renderer/hooks/useCreateTeamDraft.test.tsx
Normal file
147
src/renderer/hooks/useCreateTeamDraft.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
361
src/renderer/services/createTeamPreferences.ts
Normal file
361
src/renderer/services/createTeamPreferences.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue