fix(ci): stabilize team runtime lane validation
This commit is contained in:
parent
9d3e7ef99b
commit
55b369de96
11 changed files with 493 additions and 118 deletions
17
src/features/team-runtime-lanes/index.ts
Normal file
17
src/features/team-runtime-lanes/index.ts
Normal 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';
|
||||
1
src/features/team-runtime-lanes/main/index.ts
Normal file
1
src/features/team-runtime-lanes/main/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createTeamRuntimeLaneCoordinator } from './composition/createTeamRuntimeLaneCoordinator';
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue