diff --git a/src/features/team-runtime-lanes/index.ts b/src/features/team-runtime-lanes/index.ts new file mode 100644 index 00000000..e8ae757b --- /dev/null +++ b/src/features/team-runtime-lanes/index.ts @@ -0,0 +1,17 @@ +export type { + PlannedRuntimeMember, + PlannedTeamMemberLaneIdentity, + RuntimeLanePlannerMemberInput, + TeamRuntimeLanePlan, + TeamRuntimeLanePlanError, + TeamRuntimeLanePlanErrorReason, + TeamRuntimeLanePlanResult, + TeamRuntimeLanePlanSuccess, +} from './core/domain/planTeamRuntimeLanes'; +export { + buildPlannedMemberLaneIdentity, + fromProvisioningMembers, + isMixedOpenCodeSideLanePlan, + isPureOpenCodeLanePlan, + planTeamRuntimeLanes, +} from './core/domain/planTeamRuntimeLanes'; diff --git a/src/features/team-runtime-lanes/main/index.ts b/src/features/team-runtime-lanes/main/index.ts new file mode 100644 index 00000000..04b59b23 --- /dev/null +++ b/src/features/team-runtime-lanes/main/index.ts @@ -0,0 +1 @@ +export { createTeamRuntimeLaneCoordinator } from './composition/createTeamRuntimeLaneCoordinator'; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a3f5fab8..402e1308 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,10 +1,7 @@ +import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan } from '@features/team-runtime-lanes'; import { yieldToEventLoop } from '@main/utils/asyncYield'; import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; -import { - fromProvisioningMembers, - isMixedOpenCodeSideLanePlan, -} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, @@ -18,9 +15,9 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; -import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; @@ -45,11 +42,11 @@ import { mergeLiveLeadProcessMessages, } from './mergeLiveLeadProcessMessages'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; -import { TeamConfigReader } from './TeamConfigReader'; import { choosePreferredLaunchSnapshot, readBootstrapLaunchSnapshot, } from './TeamBootstrapStateReader'; +import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamKanbanManager } from './TeamKanbanManager'; @@ -69,6 +66,7 @@ import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; +import type { TeamMetaFile } from './TeamMetaStore'; import type { AddMemberRequest, AttachmentMeta, @@ -88,8 +86,8 @@ import type { TeamConfig, TeamCreateConfigRequest, TeamMember, - TeamMemberSnapshot, TeamMemberActivityMeta, + TeamMemberSnapshot, TeamProcess, TeamProviderId, TeamSummary, @@ -101,7 +99,6 @@ import type { UpdateKanbanPatch, } from '@shared/types'; import type { AgentTeamsController } from 'agent-teams-controller'; -import type { TeamMetaFile } from './TeamMetaStore'; const { createController } = agentTeamsControllerModule; @@ -308,7 +305,7 @@ function toProvisioningMemberShape( | 'fastMode' | 'removedAt' >[] -): Array<{ +): { name: string; role?: string; workflow?: string; @@ -318,7 +315,7 @@ function toProvisioningMemberShape( model?: string; effort?: TeamMember['effort']; fastMode?: TeamMember['fastMode']; -}> { +}[] { return members .filter((member) => !member.removedAt) .filter((member) => { diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 167cee29..a4e89493 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -6,12 +6,12 @@ import type { MemberLaunchState, MemberSpawnLivenessSource, MemberSpawnStatusEntry, - ProviderModelLaunchIdentity, PersistedTeamLaunchMemberSources, PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, PersistedTeamLaunchSnapshot, PersistedTeamLaunchSummary, + ProviderModelLaunchIdentity, TeamLaunchAggregateState, } from '@shared/types'; @@ -519,10 +519,10 @@ export function snapshotToMemberSpawnStatuses( } else if (entry.launchState === 'confirmed_alive') { status = 'online'; livenessSource = 'heartbeat'; - } else if (entry.launchState === 'runtime_pending_permission') { - status = entry.runtimeAlive ? 'online' : 'waiting'; - livenessSource = entry.runtimeAlive ? 'process' : undefined; - } else if (entry.launchState === 'runtime_pending_bootstrap') { + } else if ( + entry.launchState === 'runtime_pending_permission' || + entry.launchState === 'runtime_pending_bootstrap' + ) { status = entry.runtimeAlive ? 'online' : 'waiting'; livenessSource = entry.runtimeAlive ? 'process' : undefined; } else { diff --git a/src/main/services/team/TeamLaunchSummaryProjection.ts b/src/main/services/team/TeamLaunchSummaryProjection.ts index 2ee602dc..da1fe435 100644 --- a/src/main/services/team/TeamLaunchSummaryProjection.ts +++ b/src/main/services/team/TeamLaunchSummaryProjection.ts @@ -1,10 +1,8 @@ -import { - isMixedOpenCodeSideLanePlan, - planTeamRuntimeLanes, -} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; +import { isMixedOpenCodeSideLanePlan, planTeamRuntimeLanes } from '@features/team-runtime-lanes'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + import { shouldIgnoreTerminalBootstrapOnlyPendingSnapshot } from './TeamBootstrapStateReader'; import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator'; -import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types'; diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 7d088e80..5251334c 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -15,6 +15,7 @@ export interface McpLaunchSpec { const MCP_SERVER_NAME = 'agent-teams'; const logger = createLogger('Service:TeamMcpConfigBuilder'); const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; +const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; /** * Stale configs older than this are removed on startup (best-effort). * 7 days is intentionally long: respawnAfterAuthFailure() reuses saved @@ -85,6 +86,14 @@ async function pathExists(targetPath: string): Promise { } } +function shouldRetryMcpConfigRemoval(error: NodeJS.ErrnoException): boolean { + return error.code === 'EPERM' || error.code === 'EBUSY'; +} + +async function waitForRetry(delayMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + /** Check that both index.js and package.json exist in a directory. */ async function hasValidServerCopy(dir: string): Promise { return ( @@ -284,12 +293,28 @@ export class TeamMcpConfigBuilder { /** Delete a single MCP config file (best-effort). */ async removeConfigFile(configPath: string): Promise { - try { - await fs.promises.unlink(configPath); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code !== 'ENOENT') { + for (let attempt = 0; attempt <= MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) { + try { + await fs.promises.unlink(configPath); + return; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + return; + } + if ( + shouldRetryMcpConfigRemoval(err) && + attempt < MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length + ) { + await waitForRetry(MCP_CONFIG_REMOVE_RETRY_DELAYS_MS[attempt]); + continue; + } + if (shouldRetryMcpConfigRemoval(err)) { + logger.debug(`Deferred MCP config cleanup for ${configPath}: ${err.message}`); + return; + } logger.warn(`Failed to remove MCP config ${configPath}: ${err.message}`); + return; } } } diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 5905cc3b..a2f9d6c3 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -1,17 +1,17 @@ +import { buildPlannedMemberLaneIdentity } from '@features/team-runtime-lanes'; import { getMemberColorByName } from '@shared/constants/memberColors'; -import { buildPlannedMemberLaneIdentity } from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; -import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; -import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; -import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; +import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import type { - TeamConfig, PersistedTeamLaunchSnapshot, + TeamConfig, TeamMember, TeamMemberSnapshot, TeamProviderBackendId, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b576437a..f71b16b0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,7 +1,3 @@ -import { - killTmuxPaneForCurrentPlatformSync, - listTmuxPanePidsForCurrentPlatform, -} from '@features/tmux-installer/main'; import { resolveAnthropicFastMode, resolveAnthropicRuntimeSelection, @@ -16,8 +12,12 @@ import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan, type TeamRuntimeLanePlan, -} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; -import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator'; +} from '@features/team-runtime-lanes'; +import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main'; +import { + killTmuxPaneForCurrentPlatformSync, + listTmuxPanePidsForCurrentPlatform, +} from '@features/tmux-installer/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -53,8 +53,8 @@ import { import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; -import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; @@ -111,6 +111,29 @@ import { } from '../runtime/providerModelProbe'; import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; +import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; +import { + type RuntimeDeliveryDestinationPort, + RuntimeDeliveryDestinationRegistry, + RuntimeDeliveryReconciler, + RuntimeDeliveryService, +} from './opencode/delivery/RuntimeDeliveryService'; +import { + clearOpenCodeRuntimeLaneStorage, + getOpenCodeLaneScopedRuntimeFilePath, + getOpenCodeRuntimeRunTombstonesPath, + getOpenCodeTeamRuntimeDirectory, + migrateLegacyOpenCodeRuntimeState, + readOpenCodeRuntimeLaneIndex, + recoverStaleOpenCodeRuntimeLaneIndexEntry, + removeOpenCodeRuntimeLaneIndexEntry, + upsertOpenCodeRuntimeLaneIndexEntry, +} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { + createRuntimeRunTombstoneStore, + type RuntimeEvidenceKind, +} from './opencode/store/RuntimeRunTombstoneStore'; +import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; import { peekAutoResumeService } from './AutoResumeService'; @@ -147,44 +170,22 @@ import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; -import { - TeamRuntimeAdapterRegistry, - type TeamLaunchRuntimeAdapter, - type OpenCodeTeamRuntimeMessageInput, - type OpenCodeTeamRuntimeMessageResult, - type TeamRuntimeLaunchInput, - type TeamRuntimeLaunchResult, - type TeamRuntimeMemberLaunchEvidence, - type TeamRuntimePrepareResult, - type TeamRuntimeStopInput, -} from './runtime'; -import { - RuntimeDeliveryDestinationRegistry, - RuntimeDeliveryReconciler, - RuntimeDeliveryService, - type RuntimeDeliveryDestinationPort, -} from './opencode/delivery/RuntimeDeliveryService'; -import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; -import { - clearOpenCodeRuntimeLaneStorage, - getOpenCodeLaneScopedRuntimeFilePath, - getOpenCodeRuntimeRunTombstonesPath, - getOpenCodeTeamRuntimeDirectory, - migrateLegacyOpenCodeRuntimeState, - readOpenCodeRuntimeLaneIndex, - recoverStaleOpenCodeRuntimeLaneIndexEntry, - removeOpenCodeRuntimeLaneIndexEntry, - upsertOpenCodeRuntimeLaneIndexEntry, -} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; -import { - createRuntimeRunTombstoneStore, - type RuntimeEvidenceKind, -} from './opencode/store/RuntimeRunTombstoneStore'; -import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; +import type { + OpenCodeTeamRuntimeMessageInput, + OpenCodeTeamRuntimeMessageResult, + TeamLaunchRuntimeAdapter, + TeamRuntimeAdapterRegistry, + TeamRuntimeLaunchInput, + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, + TeamRuntimePrepareResult, + TeamRuntimeStopInput, +} from './runtime'; + /** * Kill a team CLI process using SIGKILL (uncatchable). * @@ -240,10 +241,10 @@ interface OpenCodeRuntimeControlAck { } import type { - CliProviderModelCatalog, - CliProviderStatus, ActiveToolCall, + CliProviderModelCatalog, CliProviderRuntimeCapabilities, + CliProviderStatus, CrossTeamSendResult, EffortLevel, InboxMessage, @@ -254,9 +255,9 @@ import type { MemberSpawnStatusEntry, PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, + PersistedTeamLaunchSnapshot, PersistedTeamLaunchSummary, ProviderModelLaunchIdentity, - PersistedTeamLaunchSnapshot, TeamAgentRuntimeBackendType, TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot, @@ -4171,13 +4172,13 @@ export class TeamProvisioningService { return Boolean(runs && runs.size > 0); } - private getSecondaryRuntimeRuns(teamName: string): Array<{ + private getSecondaryRuntimeRuns(teamName: string): { runId: string; providerId: 'opencode'; laneId: string; memberName: string; cwd?: string; - }> { + }[] { return Array.from(this.secondaryRuntimeRunByTeam.get(teamName)?.values() ?? []); } @@ -4312,7 +4313,7 @@ export class TeamProvisioningService { color: getMemberColorByName(member.name.trim()), joinedAt: typeof (member as { joinedAt?: unknown }).joinedAt === 'number' - ? ((member as { joinedAt?: number }).joinedAt as number) + ? (member as { joinedAt?: number }).joinedAt! : Date.now(), })) ); @@ -4355,8 +4356,7 @@ export class TeamProvisioningService { const cached = this.persistedTranscriptClaudeLogsCache.get(teamName); if ( - cached && - cached.transcriptPath === transcriptPath && + cached?.transcriptPath === transcriptPath && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size ) { @@ -7866,13 +7866,13 @@ export class TeamProvisioningService { { kind: 'ready' | 'warning' | 'unavailable'; reason?: string } >(); let resolvedDefaultModelId: string | null | undefined; - const plannedModels: Array< + const plannedModels: ( | { requestedModelId: string; targetModelId: string } | { requestedModelId: string; immediateOutcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string }; } - > = []; + )[] = []; const recordOutcome = ( requestedModelId: string, @@ -13045,7 +13045,7 @@ export class TeamProvisioningService { launchIdentity: teamMeta?.launchIdentity ?? null, }; const primaryMembers: TeamMember[] = []; - const secondaryMembers: Array<{ + const secondaryMembers: { laneId: string; member: TeamMember; leadDefaults: typeof leadDefaults; @@ -13061,7 +13061,7 @@ export class TeamProvisioningService { diagnostics?: string[]; }; pendingReason?: string; - }> = []; + }[] = []; let recoveredAny = false; for (const member of activeMembers) { diff --git a/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts index 6e1c55ec..39ef82d7 100644 --- a/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts +++ b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts @@ -11,7 +11,7 @@ export function buildProviderPrepareModelCacheKey({ providerId: TeamProviderId; backendSummary: string | null | undefined; limitContext: boolean; - runtimeStatusSignature?: string | null | undefined; + runtimeStatusSignature?: string | null; }): string { return [ cwd, diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index bb945b7f..0d96159a 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -391,6 +391,102 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('recovers mixed Anthropic/OpenCode launch truth from persisted state after service restart', async () => { + const teamName = 'mixed-persisted-anthropic-opencode-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' }); + await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' }); + await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' }); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'anthropic', + model: 'haiku', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'anthropic', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + tom: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + }), + }, + }); + + const restartedService = new TeamProvisioningService(); + const statuses = await restartedService.getMemberSpawnStatuses(teamName); + + expect(statuses.expectedMembers).toEqual(['alice', 'bob', 'tom']); + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + }); + + const runtimeSnapshot = await restartedService.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members.alice).toMatchObject({ + providerId: 'anthropic', + laneKind: 'primary', + runtimeModel: 'haiku', + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + runtimeModel: 'opencode/minimax-m2.5-free', + }); + expect(runtimeSnapshot.members.tom).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + runtimeModel: 'opencode/nemotron-3-super-free', + }); + }); + it('recovers mixed Gemini failure and split OpenCode lane truth after service restart', async () => { const teamName = 'mixed-persisted-gemini-failure-opencode-split-safe-e2e'; await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true }); @@ -686,6 +782,106 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('keeps OpenCode side-lane pid and memory visible after Anthropic mixed recovery', async () => { + const teamName = 'mixed-anthropic-opencode-memory-safe-e2e'; + const sharedHostPid = 41_414; + const sharedRssBytes = 207.6 * 1024 * 1024; + await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' }); + await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' }); + await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' }); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'anthropic', + model: 'haiku', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'anthropic', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + tom: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + pendingPermissionRequestIds: ['perm-tom'], + }), + }, + }); + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = async () => + new Map([ + [ + 'bob', + { + alive: true, + metricsPid: sharedHostPid, + model: 'opencode/minimax-m2.5-free', + }, + ], + [ + 'tom', + { + alive: true, + metricsPid: sharedHostPid, + model: 'opencode/nemotron-3-super-free', + }, + ], + ]); + (svc as any).readProcessRssBytesByPid = async () => new Map([[sharedHostPid, sharedRssBytes]]); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + + expect(runtimeSnapshot.members.alice).toMatchObject({ + providerId: 'anthropic', + laneKind: 'primary', + runtimeModel: 'haiku', + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + alive: true, + restartable: false, + pid: sharedHostPid, + runtimeModel: 'opencode/minimax-m2.5-free', + rssBytes: sharedRssBytes, + }); + expect(runtimeSnapshot.members.tom).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + alive: true, + restartable: false, + pid: sharedHostPid, + runtimeModel: 'opencode/nemotron-3-super-free', + rssBytes: sharedRssBytes, + }); + }); + it('infers OpenCode runtime provider from model after restart when provider metadata is missing', async () => { const teamName = 'mixed-opencode-model-inference-safe-e2e'; const sharedHostPid = 24_243; @@ -1090,6 +1286,77 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('keeps Anthropic primary online while mixed OpenCode lanes split ready and bootstrap pending', async () => { + const teamName = 'mixed-anthropic-opencode-split-bootstrap-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'launching', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + await waitForCondition(() => run.memberSpawnStatuses.get('bob')?.launchState === 'confirmed_alive'); + await waitForCondition( + () => run.memberSpawnStatuses.get('tom')?.launchState === 'runtime_pending_bootstrap' + ); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + }); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members.alice).toMatchObject({ + providerId: 'anthropic', + laneKind: 'primary', + alive: true, + runtimeModel: 'haiku', + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneKind: 'secondary', + alive: true, + runtimeModel: 'opencode/minimax-m2.5-free', + }); + expect(runtimeSnapshot.members.tom).toMatchObject({ + providerId: 'opencode', + laneKind: 'secondary', + alive: true, + runtimeModel: 'opencode/nemotron-3-super-free', + }); + }); + it('keeps mixed launch partial when Gemini primary fails and OpenCode lanes split ready and pending', async () => { const teamName = 'mixed-gemini-failed-opencode-split-safe-e2e'; await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true }); @@ -2111,6 +2378,7 @@ describe('Team agent launch matrix safe e2e', () => { }); type FakeMemberOutcome = 'confirmed' | 'permission' | 'launching' | 'failed'; +type MixedPrimaryProviderId = 'anthropic' | 'codex'; class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { readonly providerId = 'opencode' as const; @@ -2298,7 +2566,7 @@ class RejectingBlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter async function waitForCondition(assertion: () => boolean): Promise { const startedAt = Date.now(); - while (Date.now() - startedAt < 2_000) { + while (Date.now() - startedAt < 5_000) { if (assertion()) { return; } @@ -2321,8 +2589,13 @@ async function removeTempDirWithRetries(dir: string): Promise { throw lastError; } -function createMixedLiveRun(input: { teamName: string; projectPath: string }): any { +function createMixedLiveRun(input: { + teamName: string; + projectPath: string; + primaryProviderId?: MixedPrimaryProviderId; +}): any { const now = '2026-04-23T10:00:00.000Z'; + const primary = getMixedPrimaryFixture(input.primaryProviderId); return { runId: `run-${input.teamName}`, teamName: input.teamName, @@ -2335,9 +2608,9 @@ function createMixedLiveRun(input: { teamName: string; projectPath: string }): a request: { teamName: input.teamName, cwd: input.projectPath, - providerId: 'codex', - providerBackendId: 'codex-native', - model: 'gpt-5.4', + providerId: primary.providerId, + providerBackendId: primary.providerBackendId, + model: primary.leadModel, skipPermissions: false, members: [], }, @@ -2349,12 +2622,12 @@ function createMixedLiveRun(input: { teamName: string; projectPath: string }): a }, onProgress: () => undefined, launchIdentity: { - providerId: 'codex', - providerBackendId: 'codex-native', - selectedModel: 'gpt-5.4', + providerId: primary.providerId, + providerBackendId: primary.providerBackendId ?? null, + selectedModel: primary.leadModel, selectedModelKind: 'explicit', - resolvedLaunchModel: 'gpt-5.4', - catalogId: 'gpt-5.4', + resolvedLaunchModel: primary.leadModel, + catalogId: primary.leadModel, catalogSource: 'bundled', catalogFetchedAt: now, selectedEffort: 'medium', @@ -2368,18 +2641,18 @@ function createMixedLiveRun(input: { teamName: string; projectPath: string }): a { name: 'alice', role: 'Reviewer', - providerId: 'codex', - providerBackendId: 'codex-native', - model: 'gpt-5.4-mini', + providerId: primary.providerId, + providerBackendId: primary.providerBackendId, + model: primary.memberModel, }, ], allEffectiveMembers: [ { name: 'alice', role: 'Reviewer', - providerId: 'codex', - providerBackendId: 'codex-native', - model: 'gpt-5.4-mini', + providerId: primary.providerId, + providerBackendId: primary.providerBackendId, + model: primary.memberModel, }, { name: 'bob', @@ -2506,8 +2779,10 @@ async function writeMixedTeamConfig(input: { teamName: string; projectPath: string; includeGeminiPrimary?: boolean; + primaryProviderId?: MixedPrimaryProviderId; }): Promise { const teamDir = path.join(getTeamsBasePath(), input.teamName); + const primary = getMixedPrimaryFixture(input.primaryProviderId); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'config.json'), @@ -2515,23 +2790,29 @@ async function writeMixedTeamConfig(input: { { name: input.teamName, projectPath: input.projectPath, - providerId: 'codex', - providerBackendId: 'codex-native', - model: 'gpt-5.4', + providerId: primary.providerId, + ...(primary.providerBackendId + ? { providerBackendId: primary.providerBackendId } + : {}), + model: primary.leadModel, members: [ { name: 'team-lead', agentType: 'team-lead', - providerId: 'codex', - providerBackendId: 'codex-native', - model: 'gpt-5.4', + providerId: primary.providerId, + ...(primary.providerBackendId + ? { providerBackendId: primary.providerBackendId } + : {}), + model: primary.leadModel, }, { name: 'alice', role: 'Reviewer', - providerId: 'codex', - providerBackendId: 'codex-native', - model: 'gpt-5.4-mini', + providerId: primary.providerId, + ...(primary.providerBackendId + ? { providerBackendId: primary.providerBackendId } + : {}), + model: primary.memberModel, }, ...(input.includeGeminiPrimary ? [ @@ -2642,8 +2923,37 @@ function mixedMemberState(overrides: Record): Record { +function getMixedPrimaryFixture( + providerId: MixedPrimaryProviderId = 'codex' +): { + providerId: MixedPrimaryProviderId; + providerBackendId?: string; + leadModel: string; + memberModel: string; +} { + if (providerId === 'anthropic') { + return { + providerId, + leadModel: 'sonnet', + memberModel: 'haiku', + }; + } + + return { + providerId, + providerBackendId: 'codex-native', + leadModel: 'gpt-5.4', + memberModel: 'gpt-5.4-mini', + }; +} + +async function writeTeamMeta( + teamName: string, + projectPath: string, + options: { primaryProviderId?: MixedPrimaryProviderId } = {} +): Promise { const teamDir = path.join(getTeamsBasePath(), teamName); + const primary = getMixedPrimaryFixture(options.primaryProviderId); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'team.meta.json'), @@ -2651,9 +2961,11 @@ async function writeTeamMeta(teamName: string, projectPath: string): Promise { const teamDir = path.join(getTeamsBasePath(), teamName); + const primary = getMixedPrimaryFixture(options.primaryProviderId); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( path.join(teamDir, 'members.meta.json'), `${JSON.stringify( { version: 1, - providerBackendId: 'codex-native', + ...(primary.providerBackendId ? { providerBackendId: primary.providerBackendId } : {}), members: [ { name: 'alice', - providerId: 'codex', - providerBackendId: 'codex-native', - model: 'gpt-5.4-mini', + providerId: primary.providerId, + ...(primary.providerBackendId + ? { providerBackendId: primary.providerBackendId } + : {}), + model: primary.memberModel, }, ...(options.includeGeminiPrimary ? [ diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 9f837af1..fa33b110 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -346,6 +346,28 @@ describe('TeamMcpConfigBuilder', () => { await builder.removeConfigFile(bogusPath); }); + it('removeConfigFile defers Windows locked temp config cleanup without warning', async () => { + const builder = new TeamMcpConfigBuilder(); + const configPath = path.join( + tempAppData, + 'mcp-configs', + `agent-teams-mcp-${process.pid}-locked.json` + ); + const originalUnlink = fs.promises.unlink.bind(fs.promises); + const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (targetPath) => { + if (targetPath === configPath) { + const error = new Error('EPERM: operation not permitted, unlink') as NodeJS.ErrnoException; + error.code = 'EPERM'; + throw error; + } + await originalUnlink(targetPath); + }); + + await builder.removeConfigFile(configPath); + + expect(unlinkSpy).toHaveBeenCalledTimes(4); + }); + // ── Cleanup: gcOwnConfigs ── it('gcOwnConfigs removes only files owned by current pid', async () => {