fix(ci): stabilize team runtime lane validation

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

View file

@ -0,0 +1,17 @@
export type {
PlannedRuntimeMember,
PlannedTeamMemberLaneIdentity,
RuntimeLanePlannerMemberInput,
TeamRuntimeLanePlan,
TeamRuntimeLanePlanError,
TeamRuntimeLanePlanErrorReason,
TeamRuntimeLanePlanResult,
TeamRuntimeLanePlanSuccess,
} from './core/domain/planTeamRuntimeLanes';
export {
buildPlannedMemberLaneIdentity,
fromProvisioningMembers,
isMixedOpenCodeSideLanePlan,
isPureOpenCodeLanePlan,
planTeamRuntimeLanes,
} from './core/domain/planTeamRuntimeLanes';

View file

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

View file

@ -1,10 +1,7 @@
import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan } from '@features/team-runtime-lanes';
import { yieldToEventLoop } from '@main/utils/asyncYield'; import { 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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -391,6 +391,102 @@ describe('Team agent launch matrix safe e2e', () => {
}); });
}); });
it('recovers mixed Anthropic/OpenCode launch truth from persisted state after service restart', async () => {
const teamName = 'mixed-persisted-anthropic-opencode-safe-e2e';
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
await writeMixedTeamLaunchState({
teamName,
members: {
alice: mixedMemberState({
providerId: 'anthropic',
model: 'haiku',
laneId: 'primary',
laneKind: 'primary',
laneOwnerProviderId: 'anthropic',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
}),
bob: mixedMemberState({
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
laneId: 'secondary:opencode:bob',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
}),
tom: mixedMemberState({
providerId: 'opencode',
model: 'opencode/nemotron-3-super-free',
laneId: 'secondary:opencode:tom',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
}),
},
});
const restartedService = new TeamProvisioningService();
const statuses = await restartedService.getMemberSpawnStatuses(teamName);
expect(statuses.expectedMembers).toEqual(['alice', 'bob', 'tom']);
expect(statuses.teamLaunchState).toBe('partial_pending');
expect(statuses.summary).toMatchObject({
confirmedCount: 2,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
});
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
hardFailure: false,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
});
const runtimeSnapshot = await restartedService.getTeamAgentRuntimeSnapshot(teamName);
expect(runtimeSnapshot.members.alice).toMatchObject({
providerId: 'anthropic',
laneKind: 'primary',
runtimeModel: 'haiku',
});
expect(runtimeSnapshot.members.bob).toMatchObject({
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
laneKind: 'secondary',
runtimeModel: 'opencode/minimax-m2.5-free',
});
expect(runtimeSnapshot.members.tom).toMatchObject({
providerId: 'opencode',
laneId: 'secondary:opencode:tom',
laneKind: 'secondary',
runtimeModel: 'opencode/nemotron-3-super-free',
});
});
it('recovers mixed Gemini failure and split OpenCode lane truth after service restart', async () => { 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
? [ ? [

View file

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