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 { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import {
|
|
||||||
fromProvisioningMembers,
|
|
||||||
isMixedOpenCodeSideLanePlan,
|
|
||||||
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
|
|
||||||
import {
|
import {
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
|
|
@ -18,9 +15,9 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
|
|
@ -45,11 +42,11 @@ import {
|
||||||
mergeLiveLeadProcessMessages,
|
mergeLiveLeadProcessMessages,
|
||||||
} from './mergeLiveLeadProcessMessages';
|
} from './mergeLiveLeadProcessMessages';
|
||||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
|
||||||
import {
|
import {
|
||||||
choosePreferredLaunchSnapshot,
|
choosePreferredLaunchSnapshot,
|
||||||
readBootstrapLaunchSnapshot,
|
readBootstrapLaunchSnapshot,
|
||||||
} from './TeamBootstrapStateReader';
|
} from './TeamBootstrapStateReader';
|
||||||
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamInboxWriter } from './TeamInboxWriter';
|
import { TeamInboxWriter } from './TeamInboxWriter';
|
||||||
import { TeamKanbanManager } from './TeamKanbanManager';
|
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||||
|
|
@ -69,6 +66,7 @@ import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||||
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
||||||
|
import type { TeamMetaFile } from './TeamMetaStore';
|
||||||
import type {
|
import type {
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
AttachmentMeta,
|
AttachmentMeta,
|
||||||
|
|
@ -88,8 +86,8 @@ import type {
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
TeamMemberSnapshot,
|
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
|
TeamMemberSnapshot,
|
||||||
TeamProcess,
|
TeamProcess,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
|
|
@ -101,7 +99,6 @@ import type {
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { AgentTeamsController } from 'agent-teams-controller';
|
import type { AgentTeamsController } from 'agent-teams-controller';
|
||||||
import type { TeamMetaFile } from './TeamMetaStore';
|
|
||||||
|
|
||||||
const { createController } = agentTeamsControllerModule;
|
const { createController } = agentTeamsControllerModule;
|
||||||
|
|
||||||
|
|
@ -308,7 +305,7 @@ function toProvisioningMemberShape(
|
||||||
| 'fastMode'
|
| 'fastMode'
|
||||||
| 'removedAt'
|
| 'removedAt'
|
||||||
>[]
|
>[]
|
||||||
): Array<{
|
): {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
|
@ -318,7 +315,7 @@ function toProvisioningMemberShape(
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: TeamMember['effort'];
|
effort?: TeamMember['effort'];
|
||||||
fastMode?: TeamMember['fastMode'];
|
fastMode?: TeamMember['fastMode'];
|
||||||
}> {
|
}[] {
|
||||||
return members
|
return members
|
||||||
.filter((member) => !member.removedAt)
|
.filter((member) => !member.removedAt)
|
||||||
.filter((member) => {
|
.filter((member) => {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import type {
|
||||||
MemberLaunchState,
|
MemberLaunchState,
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ProviderModelLaunchIdentity,
|
|
||||||
PersistedTeamLaunchMemberSources,
|
PersistedTeamLaunchMemberSources,
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
PersistedTeamLaunchSummary,
|
PersistedTeamLaunchSummary,
|
||||||
|
ProviderModelLaunchIdentity,
|
||||||
TeamLaunchAggregateState,
|
TeamLaunchAggregateState,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -519,10 +519,10 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
} else if (entry.launchState === 'confirmed_alive') {
|
} else if (entry.launchState === 'confirmed_alive') {
|
||||||
status = 'online';
|
status = 'online';
|
||||||
livenessSource = 'heartbeat';
|
livenessSource = 'heartbeat';
|
||||||
} else if (entry.launchState === 'runtime_pending_permission') {
|
} else if (
|
||||||
status = entry.runtimeAlive ? 'online' : 'waiting';
|
entry.launchState === 'runtime_pending_permission' ||
|
||||||
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
entry.launchState === 'runtime_pending_bootstrap'
|
||||||
} else if (entry.launchState === 'runtime_pending_bootstrap') {
|
) {
|
||||||
status = entry.runtimeAlive ? 'online' : 'waiting';
|
status = entry.runtimeAlive ? 'online' : 'waiting';
|
||||||
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import {
|
import { isMixedOpenCodeSideLanePlan, planTeamRuntimeLanes } from '@features/team-runtime-lanes';
|
||||||
isMixedOpenCodeSideLanePlan,
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
planTeamRuntimeLanes,
|
|
||||||
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
|
|
||||||
import { shouldIgnoreTerminalBootstrapOnlyPendingSnapshot } from './TeamBootstrapStateReader';
|
import { shouldIgnoreTerminalBootstrapOnlyPendingSnapshot } from './TeamBootstrapStateReader';
|
||||||
import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator';
|
import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
|
||||||
|
|
||||||
import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types';
|
import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export interface McpLaunchSpec {
|
||||||
const MCP_SERVER_NAME = 'agent-teams';
|
const MCP_SERVER_NAME = 'agent-teams';
|
||||||
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
||||||
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
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).
|
* Stale configs older than this are removed on startup (best-effort).
|
||||||
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
|
* 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. */
|
/** Check that both index.js and package.json exist in a directory. */
|
||||||
async function hasValidServerCopy(dir: string): Promise<boolean> {
|
async function hasValidServerCopy(dir: string): Promise<boolean> {
|
||||||
return (
|
return (
|
||||||
|
|
@ -284,12 +293,28 @@ export class TeamMcpConfigBuilder {
|
||||||
|
|
||||||
/** Delete a single MCP config file (best-effort). */
|
/** Delete a single MCP config file (best-effort). */
|
||||||
async removeConfigFile(configPath: string): Promise<void> {
|
async removeConfigFile(configPath: string): Promise<void> {
|
||||||
try {
|
for (let attempt = 0; attempt <= MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||||
await fs.promises.unlink(configPath);
|
try {
|
||||||
} catch (error) {
|
await fs.promises.unlink(configPath);
|
||||||
const err = error as NodeJS.ErrnoException;
|
return;
|
||||||
if (err.code !== 'ENOENT') {
|
} 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}`);
|
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 { 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 {
|
import {
|
||||||
createCliAutoSuffixNameGuard,
|
createCliAutoSuffixNameGuard,
|
||||||
createCliProvisionerNameGuard,
|
createCliProvisionerNameGuard,
|
||||||
} from '@shared/utils/teamMemberName';
|
} 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 { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TeamConfig,
|
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
|
TeamConfig,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
TeamMemberSnapshot,
|
TeamMemberSnapshot,
|
||||||
TeamProviderBackendId,
|
TeamProviderBackendId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
import {
|
|
||||||
killTmuxPaneForCurrentPlatformSync,
|
|
||||||
listTmuxPanePidsForCurrentPlatform,
|
|
||||||
} from '@features/tmux-installer/main';
|
|
||||||
import {
|
import {
|
||||||
resolveAnthropicFastMode,
|
resolveAnthropicFastMode,
|
||||||
resolveAnthropicRuntimeSelection,
|
resolveAnthropicRuntimeSelection,
|
||||||
|
|
@ -16,8 +12,12 @@ import {
|
||||||
fromProvisioningMembers,
|
fromProvisioningMembers,
|
||||||
isMixedOpenCodeSideLanePlan,
|
isMixedOpenCodeSideLanePlan,
|
||||||
type TeamRuntimeLanePlan,
|
type TeamRuntimeLanePlan,
|
||||||
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
|
} from '@features/team-runtime-lanes';
|
||||||
import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator';
|
import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main';
|
||||||
|
import {
|
||||||
|
killTmuxPaneForCurrentPlatformSync,
|
||||||
|
listTmuxPanePidsForCurrentPlatform,
|
||||||
|
} from '@features/tmux-installer/main';
|
||||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
|
|
@ -53,8 +53,8 @@ import {
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
|
||||||
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||||
|
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||||
|
|
@ -111,6 +111,29 @@ import {
|
||||||
} from '../runtime/providerModelProbe';
|
} from '../runtime/providerModelProbe';
|
||||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
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 { buildActionModeProtocol } from './actionModeInstructions';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { peekAutoResumeService } from './AutoResumeService';
|
import { peekAutoResumeService } from './AutoResumeService';
|
||||||
|
|
@ -147,44 +170,22 @@ import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
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 { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
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).
|
* Kill a team CLI process using SIGKILL (uncatchable).
|
||||||
*
|
*
|
||||||
|
|
@ -240,10 +241,10 @@ interface OpenCodeRuntimeControlAck {
|
||||||
}
|
}
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CliProviderModelCatalog,
|
|
||||||
CliProviderStatus,
|
|
||||||
ActiveToolCall,
|
ActiveToolCall,
|
||||||
|
CliProviderModelCatalog,
|
||||||
CliProviderRuntimeCapabilities,
|
CliProviderRuntimeCapabilities,
|
||||||
|
CliProviderStatus,
|
||||||
CrossTeamSendResult,
|
CrossTeamSendResult,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
|
@ -254,9 +255,9 @@ import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
|
PersistedTeamLaunchSnapshot,
|
||||||
PersistedTeamLaunchSummary,
|
PersistedTeamLaunchSummary,
|
||||||
ProviderModelLaunchIdentity,
|
ProviderModelLaunchIdentity,
|
||||||
PersistedTeamLaunchSnapshot,
|
|
||||||
TeamAgentRuntimeBackendType,
|
TeamAgentRuntimeBackendType,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
TeamAgentRuntimeSnapshot,
|
TeamAgentRuntimeSnapshot,
|
||||||
|
|
@ -4171,13 +4172,13 @@ export class TeamProvisioningService {
|
||||||
return Boolean(runs && runs.size > 0);
|
return Boolean(runs && runs.size > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSecondaryRuntimeRuns(teamName: string): Array<{
|
private getSecondaryRuntimeRuns(teamName: string): {
|
||||||
runId: string;
|
runId: string;
|
||||||
providerId: 'opencode';
|
providerId: 'opencode';
|
||||||
laneId: string;
|
laneId: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
}> {
|
}[] {
|
||||||
return Array.from(this.secondaryRuntimeRunByTeam.get(teamName)?.values() ?? []);
|
return Array.from(this.secondaryRuntimeRunByTeam.get(teamName)?.values() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4312,7 +4313,7 @@ export class TeamProvisioningService {
|
||||||
color: getMemberColorByName(member.name.trim()),
|
color: getMemberColorByName(member.name.trim()),
|
||||||
joinedAt:
|
joinedAt:
|
||||||
typeof (member as { joinedAt?: unknown }).joinedAt === 'number'
|
typeof (member as { joinedAt?: unknown }).joinedAt === 'number'
|
||||||
? ((member as { joinedAt?: number }).joinedAt as number)
|
? (member as { joinedAt?: number }).joinedAt!
|
||||||
: Date.now(),
|
: Date.now(),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -4355,8 +4356,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const cached = this.persistedTranscriptClaudeLogsCache.get(teamName);
|
const cached = this.persistedTranscriptClaudeLogsCache.get(teamName);
|
||||||
if (
|
if (
|
||||||
cached &&
|
cached?.transcriptPath === transcriptPath &&
|
||||||
cached.transcriptPath === transcriptPath &&
|
|
||||||
cached.mtimeMs === stat.mtimeMs &&
|
cached.mtimeMs === stat.mtimeMs &&
|
||||||
cached.size === stat.size
|
cached.size === stat.size
|
||||||
) {
|
) {
|
||||||
|
|
@ -7866,13 +7866,13 @@ export class TeamProvisioningService {
|
||||||
{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
||||||
>();
|
>();
|
||||||
let resolvedDefaultModelId: string | null | undefined;
|
let resolvedDefaultModelId: string | null | undefined;
|
||||||
const plannedModels: Array<
|
const plannedModels: (
|
||||||
| { requestedModelId: string; targetModelId: string }
|
| { requestedModelId: string; targetModelId: string }
|
||||||
| {
|
| {
|
||||||
requestedModelId: string;
|
requestedModelId: string;
|
||||||
immediateOutcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string };
|
immediateOutcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string };
|
||||||
}
|
}
|
||||||
> = [];
|
)[] = [];
|
||||||
|
|
||||||
const recordOutcome = (
|
const recordOutcome = (
|
||||||
requestedModelId: string,
|
requestedModelId: string,
|
||||||
|
|
@ -13045,7 +13045,7 @@ export class TeamProvisioningService {
|
||||||
launchIdentity: teamMeta?.launchIdentity ?? null,
|
launchIdentity: teamMeta?.launchIdentity ?? null,
|
||||||
};
|
};
|
||||||
const primaryMembers: TeamMember[] = [];
|
const primaryMembers: TeamMember[] = [];
|
||||||
const secondaryMembers: Array<{
|
const secondaryMembers: {
|
||||||
laneId: string;
|
laneId: string;
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
leadDefaults: typeof leadDefaults;
|
leadDefaults: typeof leadDefaults;
|
||||||
|
|
@ -13061,7 +13061,7 @@ export class TeamProvisioningService {
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
};
|
};
|
||||||
pendingReason?: string;
|
pendingReason?: string;
|
||||||
}> = [];
|
}[] = [];
|
||||||
let recoveredAny = false;
|
let recoveredAny = false;
|
||||||
|
|
||||||
for (const member of activeMembers) {
|
for (const member of activeMembers) {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function buildProviderPrepareModelCacheKey({
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
backendSummary: string | null | undefined;
|
backendSummary: string | null | undefined;
|
||||||
limitContext: boolean;
|
limitContext: boolean;
|
||||||
runtimeStatusSignature?: string | null | undefined;
|
runtimeStatusSignature?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
return [
|
return [
|
||||||
cwd,
|
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 () => {
|
it('recovers mixed Gemini failure and split OpenCode lane truth after service restart', async () => {
|
||||||
const teamName = 'mixed-persisted-gemini-failure-opencode-split-safe-e2e';
|
const teamName = 'mixed-persisted-gemini-failure-opencode-split-safe-e2e';
|
||||||
await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true });
|
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 () => {
|
it('infers OpenCode runtime provider from model after restart when provider metadata is missing', async () => {
|
||||||
const teamName = 'mixed-opencode-model-inference-safe-e2e';
|
const teamName = 'mixed-opencode-model-inference-safe-e2e';
|
||||||
const sharedHostPid = 24_243;
|
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 () => {
|
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';
|
const teamName = 'mixed-gemini-failed-opencode-split-safe-e2e';
|
||||||
await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true });
|
await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true });
|
||||||
|
|
@ -2111,6 +2378,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
type FakeMemberOutcome = 'confirmed' | 'permission' | 'launching' | 'failed';
|
type FakeMemberOutcome = 'confirmed' | 'permission' | 'launching' | 'failed';
|
||||||
|
type MixedPrimaryProviderId = 'anthropic' | 'codex';
|
||||||
|
|
||||||
class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
readonly providerId = 'opencode' as const;
|
readonly providerId = 'opencode' as const;
|
||||||
|
|
@ -2298,7 +2566,7 @@ class RejectingBlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter
|
||||||
|
|
||||||
async function waitForCondition(assertion: () => boolean): Promise<void> {
|
async function waitForCondition(assertion: () => boolean): Promise<void> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
while (Date.now() - startedAt < 2_000) {
|
while (Date.now() - startedAt < 5_000) {
|
||||||
if (assertion()) {
|
if (assertion()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2321,8 +2589,13 @@ async function removeTempDirWithRetries(dir: string): Promise<void> {
|
||||||
throw lastError;
|
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 now = '2026-04-23T10:00:00.000Z';
|
||||||
|
const primary = getMixedPrimaryFixture(input.primaryProviderId);
|
||||||
return {
|
return {
|
||||||
runId: `run-${input.teamName}`,
|
runId: `run-${input.teamName}`,
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
|
|
@ -2335,9 +2608,9 @@ function createMixedLiveRun(input: { teamName: string; projectPath: string }): a
|
||||||
request: {
|
request: {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
cwd: input.projectPath,
|
cwd: input.projectPath,
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: primary.providerBackendId,
|
||||||
model: 'gpt-5.4',
|
model: primary.leadModel,
|
||||||
skipPermissions: false,
|
skipPermissions: false,
|
||||||
members: [],
|
members: [],
|
||||||
},
|
},
|
||||||
|
|
@ -2349,12 +2622,12 @@ function createMixedLiveRun(input: { teamName: string; projectPath: string }): a
|
||||||
},
|
},
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
launchIdentity: {
|
launchIdentity: {
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: primary.providerBackendId ?? null,
|
||||||
selectedModel: 'gpt-5.4',
|
selectedModel: primary.leadModel,
|
||||||
selectedModelKind: 'explicit',
|
selectedModelKind: 'explicit',
|
||||||
resolvedLaunchModel: 'gpt-5.4',
|
resolvedLaunchModel: primary.leadModel,
|
||||||
catalogId: 'gpt-5.4',
|
catalogId: primary.leadModel,
|
||||||
catalogSource: 'bundled',
|
catalogSource: 'bundled',
|
||||||
catalogFetchedAt: now,
|
catalogFetchedAt: now,
|
||||||
selectedEffort: 'medium',
|
selectedEffort: 'medium',
|
||||||
|
|
@ -2368,18 +2641,18 @@ function createMixedLiveRun(input: { teamName: string; projectPath: string }): a
|
||||||
{
|
{
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
role: 'Reviewer',
|
role: 'Reviewer',
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: primary.providerBackendId,
|
||||||
model: 'gpt-5.4-mini',
|
model: primary.memberModel,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
allEffectiveMembers: [
|
allEffectiveMembers: [
|
||||||
{
|
{
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
role: 'Reviewer',
|
role: 'Reviewer',
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: primary.providerBackendId,
|
||||||
model: 'gpt-5.4-mini',
|
model: primary.memberModel,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'bob',
|
name: 'bob',
|
||||||
|
|
@ -2506,8 +2779,10 @@ async function writeMixedTeamConfig(input: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
includeGeminiPrimary?: boolean;
|
includeGeminiPrimary?: boolean;
|
||||||
|
primaryProviderId?: MixedPrimaryProviderId;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const teamDir = path.join(getTeamsBasePath(), input.teamName);
|
const teamDir = path.join(getTeamsBasePath(), input.teamName);
|
||||||
|
const primary = getMixedPrimaryFixture(input.primaryProviderId);
|
||||||
await fs.mkdir(teamDir, { recursive: true });
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(teamDir, 'config.json'),
|
path.join(teamDir, 'config.json'),
|
||||||
|
|
@ -2515,23 +2790,29 @@ async function writeMixedTeamConfig(input: {
|
||||||
{
|
{
|
||||||
name: input.teamName,
|
name: input.teamName,
|
||||||
projectPath: input.projectPath,
|
projectPath: input.projectPath,
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
...(primary.providerBackendId
|
||||||
model: 'gpt-5.4',
|
? { providerBackendId: primary.providerBackendId }
|
||||||
|
: {}),
|
||||||
|
model: primary.leadModel,
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
name: 'team-lead',
|
name: 'team-lead',
|
||||||
agentType: 'team-lead',
|
agentType: 'team-lead',
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
...(primary.providerBackendId
|
||||||
model: 'gpt-5.4',
|
? { providerBackendId: primary.providerBackendId }
|
||||||
|
: {}),
|
||||||
|
model: primary.leadModel,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
role: 'Reviewer',
|
role: 'Reviewer',
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
...(primary.providerBackendId
|
||||||
model: 'gpt-5.4-mini',
|
? { providerBackendId: primary.providerBackendId }
|
||||||
|
: {}),
|
||||||
|
model: primary.memberModel,
|
||||||
},
|
},
|
||||||
...(input.includeGeminiPrimary
|
...(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 teamDir = path.join(getTeamsBasePath(), teamName);
|
||||||
|
const primary = getMixedPrimaryFixture(options.primaryProviderId);
|
||||||
await fs.mkdir(teamDir, { recursive: true });
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(teamDir, 'team.meta.json'),
|
path.join(teamDir, 'team.meta.json'),
|
||||||
|
|
@ -2651,9 +2961,11 @@ async function writeTeamMeta(teamName: string, projectPath: string): Promise<voi
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
...(primary.providerBackendId
|
||||||
model: 'gpt-5.4',
|
? { providerBackendId: primary.providerBackendId }
|
||||||
|
: {}),
|
||||||
|
model: primary.leadModel,
|
||||||
effort: 'medium',
|
effort: 'medium',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
|
@ -2666,22 +2978,25 @@ async function writeTeamMeta(teamName: string, projectPath: string): Promise<voi
|
||||||
|
|
||||||
async function writeMembersMeta(
|
async function writeMembersMeta(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options: { includeGeminiPrimary?: boolean } = {}
|
options: { includeGeminiPrimary?: boolean; primaryProviderId?: MixedPrimaryProviderId } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const teamDir = path.join(getTeamsBasePath(), teamName);
|
const teamDir = path.join(getTeamsBasePath(), teamName);
|
||||||
|
const primary = getMixedPrimaryFixture(options.primaryProviderId);
|
||||||
await fs.mkdir(teamDir, { recursive: true });
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(teamDir, 'members.meta.json'),
|
path.join(teamDir, 'members.meta.json'),
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
providerBackendId: 'codex-native',
|
...(primary.providerBackendId ? { providerBackendId: primary.providerBackendId } : {}),
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
providerId: 'codex',
|
providerId: primary.providerId,
|
||||||
providerBackendId: 'codex-native',
|
...(primary.providerBackendId
|
||||||
model: 'gpt-5.4-mini',
|
? { providerBackendId: primary.providerBackendId }
|
||||||
|
: {}),
|
||||||
|
model: primary.memberModel,
|
||||||
},
|
},
|
||||||
...(options.includeGeminiPrimary
|
...(options.includeGeminiPrimary
|
||||||
? [
|
? [
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,28 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
await builder.removeConfigFile(bogusPath);
|
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 ──
|
// ── Cleanup: gcOwnConfigs ──
|
||||||
|
|
||||||
it('gcOwnConfigs removes only files owned by current pid', async () => {
|
it('gcOwnConfigs removes only files owned by current pid', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue