fix(ci): stabilize team runtime lane validation

This commit is contained in:
777genius 2026-04-23 22:17:20 +03:00
parent 9d3e7ef99b
commit 55b369de96
11 changed files with 493 additions and 118 deletions

View file

@ -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';

View file

@ -0,0 +1 @@
export { createTeamRuntimeLaneCoordinator } from './composition/createTeamRuntimeLaneCoordinator';

View file

@ -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) => {

View file

@ -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 {

View file

@ -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';

View file

@ -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<boolean> {
}
}
function shouldRetryMcpConfigRemoval(error: NodeJS.ErrnoException): boolean {
return error.code === 'EPERM' || error.code === 'EBUSY';
}
async function waitForRetry(delayMs: number): Promise<void> {
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<boolean> {
return (
@ -284,12 +293,28 @@ export class TeamMcpConfigBuilder {
/** Delete a single MCP config file (best-effort). */
async removeConfigFile(configPath: string): Promise<void> {
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;
}
}
}

View file

@ -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,

View file

@ -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) {

View file

@ -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,

View file

@ -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<void> {
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<void> {
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<void> {
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<string, unknown>): Record<string, un
};
}
async function writeTeamMeta(teamName: string, projectPath: string): Promise<void> {
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<void> {
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<voi
{
version: 1,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
providerId: primary.providerId,
...(primary.providerBackendId
? { providerBackendId: primary.providerBackendId }
: {}),
model: primary.leadModel,
effort: 'medium',
createdAt: Date.now(),
},
@ -2666,22 +2978,25 @@ async function writeTeamMeta(teamName: string, projectPath: string): Promise<voi
async function writeMembersMeta(
teamName: string,
options: { includeGeminiPrimary?: boolean } = {}
options: { includeGeminiPrimary?: boolean; primaryProviderId?: MixedPrimaryProviderId } = {}
): Promise<void> {
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
? [

View file

@ -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 () => {