From 95652c8990ae74e24750e04cb9b4c593d2ff8c8e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 24 May 2026 11:37:37 +0300 Subject: [PATCH] refactor(team): extract provisioning service policies --- .../services/team/TeamProvisioningService.ts | 2173 +---------------- .../TeamProvisioningCliExitPresentation.ts | 257 ++ .../TeamProvisioningDirectRestart.ts | 188 ++ .../TeamProvisioningLaunchCompatibility.ts | 123 + .../TeamProvisioningLaunchDiagnostics.ts | 197 ++ .../TeamProvisioningLaunchFailurePolicy.ts | 131 + .../TeamProvisioningMemberIdentity.ts | 44 + ...TeamProvisioningMemberSpawnStatusPolicy.ts | 192 ++ ...amProvisioningOpenCodeDiagnosticsPolicy.ts | 216 ++ ...ovisioningOpenCodeRuntimeEvidencePolicy.ts | 620 +++++ .../TeamProvisioningRuntimeDiagnostics.ts | 172 ++ ...eamProvisioningCliExitPresentation.test.ts | 135 + .../TeamProvisioningDirectRestart.test.ts | 146 ++ ...eamProvisioningLaunchCompatibility.test.ts | 75 + .../TeamProvisioningLaunchDiagnostics.test.ts | 324 +++ ...eamProvisioningLaunchFailurePolicy.test.ts | 113 + .../TeamProvisioningMemberIdentity.test.ts | 54 + ...rovisioningMemberSpawnStatusPolicy.test.ts | 190 ++ ...visioningOpenCodeDiagnosticsPolicy.test.ts | 129 + ...oningOpenCodeRuntimeEvidencePolicy.test.ts | 488 ++++ ...TeamProvisioningRuntimeDiagnostics.test.ts | 213 ++ ...amProvisioningServiceAuditWarnings.test.ts | 3 +- 22 files changed, 4133 insertions(+), 2050 deletions(-) create mode 100644 src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts create mode 100644 src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts create mode 100644 test/main/services/team/TeamProvisioningCliExitPresentation.test.ts create mode 100644 test/main/services/team/TeamProvisioningDirectRestart.test.ts create mode 100644 test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts create mode 100644 test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts create mode 100644 test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts create mode 100644 test/main/services/team/TeamProvisioningMemberIdentity.test.ts create mode 100644 test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts create mode 100644 test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts create mode 100644 test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts create mode 100644 test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 672aaaed..511754d1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -18,7 +18,6 @@ import { } from '@features/codex-runtime-profile/main'; import { buildPlannedMemberLaneIdentity, - fromProvisioningMembers, isMixedOpenCodeSideLanePlan, type TeamRuntimeLanePlan, } from '@features/team-runtime-lanes'; @@ -154,9 +153,6 @@ import * as readline from 'readline'; import { ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS, type AnthropicTeamApiKeyHelperMaterial, - CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, - CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER, - CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, cleanupAnthropicTeamApiKeyHelperForTeam, cleanupAnthropicTeamApiKeyHelperMaterial, cleanupStaleAnthropicTeamApiKeyHelpers, @@ -204,6 +200,56 @@ import { writeDeterministicBootstrapSpecFile, writeDeterministicBootstrapUserPromptFile, } from './provisioning/TeamProvisioningBootstrapSpec'; +import { + buildCliExitFailurePresentation, + buildCombinedLogs, +} from './provisioning/TeamProvisioningCliExitPresentation'; +import { + buildDirectTmuxRestartCommand, + hasAnthropicCompatibleAuthTokenEnv, + isInteractiveShellCommand, +} from './provisioning/TeamProvisioningDirectRestart'; +import { + assertDeterministicBootstrapPrimaryMemberLimit, + assertOpenCodeNotLaunchedThroughLegacyProvisioning, + buildLargeDeterministicBootstrapWarning, + getMixedLaunchFallbackRecoveryError, + isPureOpenCodeProvisioningRequest, + mergeProvisioningWarnings, + type TeamLaunchCompatibilityReport, +} from './provisioning/TeamProvisioningLaunchCompatibility'; +import { + buildLaunchDiagnosticsFromRun, + buildWorkspaceTrustPreflightLaunchDiagnostic, + mentionsProcessTableUnavailable, + mergeLaunchDiagnosticItem, +} from './provisioning/TeamProvisioningLaunchDiagnostics'; +import { + deriveMemberLaunchState, + isAutoClearableLaunchFailureReason, + isNeverSpawnedDuringLaunchReason, +} from './provisioning/TeamProvisioningLaunchFailurePolicy'; +import { + isOpenCodeOverlayMemberRemoved, + matchesExactTeamMemberName, + matchesMemberNameOrBase, + matchesObservedMemberNameForExpected, + matchesTeamMemberIdentity, + namesMatchCaseInsensitive, +} from './provisioning/TeamProvisioningMemberIdentity'; +import { + buildRestartDuplicateUnconfirmedReason, + buildRestartGraceTimeoutReason, + buildRestartStillRunningReason, + createInitialMemberSpawnStatusEntry, + deriveTaskActivityPauseAt, + deriveTaskActivityResumeAt, + MEMBER_LAUNCH_GRACE_MS, + parseOptionalIsoMs, + shouldWarnOnMissingRegisteredMember, + shouldWarnOnUnreadableMemberAuditConfig, + summarizeMemberSpawnStatusRecord, +} from './provisioning/TeamProvisioningMemberSpawnStatusPolicy'; import { buildEffectiveTeamMemberSpec, buildEffectiveTeamMemberSpecs, @@ -212,6 +258,46 @@ import { normalizeTeamProviderLike, teamRequestIncludesCodexMember, } from './provisioning/TeamProvisioningMemberSpecs'; +import { + boundOpenCodeAppManagedBriefingText, + filterStaleOpenCodeOverlayDiagnostics, + hasRealOpenCodeFailureDiagnostic, + hasRealOpenCodeLaunchDiagnostic, + hasStaleOpenCodeDiagnostics, + hasStaleOpenCodeSecondaryLaunchDiagnostic, + isFileLockTimeoutError, + isPersistedOpenCodeSecondaryLaneMember, + promoteOpenCodePersistedFailureReasonsFromDiagnostics, +} from './provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy'; +import { + appendDiagnosticOnce, + buildOpenCodeSecondaryLaneTimingDiagnostic, + buildOpenCodeUncommittedBootstrapDiagnostic, + collectOpenCodeSecondaryLaneFailureDiagnostics, + createUnexpectedMixedSecondaryLaneFailureResult, + downgradeUncommittedOpenCodeBootstrapEvidence, + getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted, + hasOpenCodeRuntimeEntryHandle, + hasOpenCodeRuntimeHandle, + hasOpenCodeRuntimeLivenessMarker, + isBootstrapMemberEvidenceCurrentForMember, + isDefinitiveOpenCodePreLaunchFailure, + isExplicitLegacyOpenCodeBootstrap, + isMaterializedOpenCodeSessionId, + isRecoverableOpenCodeBootstrapPendingLaunchResult, + isRecoverableOpenCodeRuntimeEvidence, + isRecoverablePersistedOpenCodeRuntimeCandidate, + isRecoverablePersistedOpenCodeTerminalRuntimeCandidate, + MEMBER_BOOTSTRAP_STALL_MS, + normalizeIsoTimestamp, + normalizeRecoverableOpenCodeBootstrapPendingLaunchResult, + OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC, + promoteCommittedOpenCodeAppManagedBootstrapEvidence, + resolveOpenCodeBootstrapAcceptedAt, + selectOpenCodeSecondaryBootstrapStallDiagnostic, + shouldMarkPersistedOpenCodeBootstrapStalled, + summarizeRuntimeLaunchResultMembers, +} from './provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy'; import { buildDeterministicLaunchHydrationPrompt, buildGeminiPostLaunchHydrationPrompt, @@ -226,15 +312,31 @@ import { getBootstrapTranscriptSuccessSource, getCanonicalSendMessageFieldRule, getCanonicalSendMessageToolRule, - isBootstrapInstructionPrompt, isBootstrapTranscriptContextText, isTaskBoardSnapshotWorkCandidate, normalizeMemberDiagnosticText, shouldUseGeminiStagedLaunch, } from './provisioning/TeamProvisioningPromptBuilders'; +import { + buildRuntimeLaunchWarning, + getAnthropicFastModeDefault, + getConfiguredRuntimeBackend, + getPromptSizeSummary, + getTeamProviderLabel, + logRuntimeLaunchSnapshot, +} from './provisioning/TeamProvisioningRuntimeDiagnostics'; import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main'; export type { RuntimeBootstrapMemberMcpLaunchConfig } from './provisioning/TeamProvisioningBootstrapSpec'; +export { buildDirectTmuxRestartEnvAssignments } from './provisioning/TeamProvisioningDirectRestart'; +export { + getMixedLaunchFallbackRecoveryError, + getOpenCodeMixedProviderProvisioningError, +} from './provisioning/TeamProvisioningLaunchCompatibility'; +export { + shouldWarnOnMissingRegisteredMember, + shouldWarnOnUnreadableMemberAuditConfig, +} from './provisioning/TeamProvisioningMemberSpawnStatusPolicy'; export { buildAddMemberSpawnMessage, buildRestartMemberSpawnMessage, @@ -715,7 +817,6 @@ const VERIFY_TIMEOUT_MS = 15_000; const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000; const PROVIDER_MODEL_LIST_TIMEOUT_MS = 30_000; const PROVIDER_RUNTIME_STATUS_TIMEOUT_MS = 20_000; -const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000; function asRuntimeRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -794,10 +895,6 @@ function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata { }; } -function mentionsProcessTableUnavailable(value: string | undefined): boolean { - return /\bprocess table\b.*\bunavailable\b/i.test(value ?? ''); -} - function buildRuntimeToolMetadataDiagnostics(metadata: RuntimeToolMetadata | undefined): string[] { if (!metadata) { return []; @@ -1118,70 +1215,10 @@ const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_WARNING_THRESHOLD_MS = 20_000; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop'; -const DIRECT_TMUX_RESTART_ENV_KEYS = [ - 'CLAUDE_CONFIG_DIR', - 'CLAUDE_TEAM_CONTROL_URL', - 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', - 'CLAUDE_CODE_USE_OPENAI', - 'CLAUDE_CODE_USE_BEDROCK', - 'CLAUDE_CODE_USE_VERTEX', - 'CLAUDE_CODE_USE_FOUNDRY', - 'CLAUDE_CODE_USE_GEMINI', - 'CLAUDE_CODE_ENTRY_PROVIDER', - 'CLAUDE_CODE_GEMINI_BACKEND', - 'CLAUDE_CODE_CODEX_BACKEND', - 'CODEX_HOME', - CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, - CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, - 'ANTHROPIC_BASE_URL', - 'ANTHROPIC_AWS_WORKSPACE_ID', - 'ANTHROPIC_AWS_API_KEY', - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'GEMINI_BASE_URL', - 'GEMINI_API_VERSION', - 'GEMINI_API_KEY', - 'CODEX_API_KEY', - 'OPENAI_API_KEY', - 'GOOGLE_APPLICATION_CREDENTIALS', - 'GOOGLE_CLOUD_PROJECT', - 'GOOGLE_CLOUD_PROJECT_ID', - 'GCLOUD_PROJECT', - 'HTTPS_PROXY', - 'https_proxy', - 'HTTP_PROXY', - 'http_proxy', - 'NO_PROXY', - 'no_proxy', - 'SSL_CERT_FILE', - 'NODE_EXTRA_CA_CERTS', - 'REQUESTS_CA_BUNDLE', - 'CURL_CA_BUNDLE', -] as const; -const DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS = [ - 'CLAUDE_CODE_USE_OPENAI', - 'CLAUDE_CODE_USE_BEDROCK', - 'CLAUDE_CODE_USE_VERTEX', - 'CLAUDE_CODE_USE_FOUNDRY', - 'CLAUDE_CODE_USE_GEMINI', - 'CLAUDE_CODE_ENTRY_PROVIDER', -] as const; -const INTERACTIVE_SHELL_COMMANDS = new Set([ - 'bash', - 'zsh', - 'sh', - 'fish', - 'nu', - 'pwsh', - 'powershell', - 'cmd', - 'cmd.exe', -]); const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; const MEMBER_SPAWN_AUDIT_MIN_INTERVAL_MS = 1_500; -const MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS = 10_000; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_send', 'cross_team_list_targets', @@ -1301,96 +1338,6 @@ function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): st return mergeJsonSettingsArgs([...providerArgs, ...args]); } -function shellQuote(value: string): string { - if (value.length === 0) { - return "''"; - } - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function isInteractiveShellCommand(command: string | undefined): boolean { - const normalized = command?.trim().toLowerCase(); - if (!normalized) { - return false; - } - return INTERACTIVE_SHELL_COMMANDS.has(path.basename(normalized)); -} - -function getDirectRestartEntryProvider(providerId: TeamProviderId): string { - return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; -} - -export function buildDirectTmuxRestartEnvAssignments( - env: NodeJS.ProcessEnv, - providerId: TeamProviderId -): string { - const assignments = new Map(); - assignments.set('CLAUDECODE', '1'); - assignments.set('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1'); - - for (const key of DIRECT_TMUX_RESTART_ENV_KEYS) { - const value = env[key]; - if (typeof value === 'string' && value.length > 0) { - assignments.set(key, value); - } - } - - for (const key of DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS) { - assignments.set(key, ''); - } - assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1'); - assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId)); - if (providerId === 'anthropic') { - if (hasAnthropicCompatibleAuthTokenEnv(env)) { - assignments.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL?.trim() ?? ''); - assignments.set('ANTHROPIC_AUTH_TOKEN', env.ANTHROPIC_AUTH_TOKEN?.trim() ?? ''); - if (!env.ANTHROPIC_API_KEY?.trim()) { - assignments.set('ANTHROPIC_API_KEY', ''); - } - } else if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) { - assignments.set('ANTHROPIC_AUTH_TOKEN', ''); - } - } - if ( - providerId === 'anthropic' && - env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER - ) { - assignments.set( - CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, - CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER - ); - const settingsPath = env[CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV]; - if (typeof settingsPath === 'string') { - assignments.set(CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, settingsPath); - } - for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { - assignments.set(key, ''); - } - } - - return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' '); -} - -function buildDirectTmuxRestartCommand(input: { - cwd: string; - env: NodeJS.ProcessEnv; - providerId: TeamProviderId; - binaryPath: string; - args: string[]; -}): string { - const envAssignments = buildDirectTmuxRestartEnvAssignments(input.env, input.providerId); - const command = [ - 'cd', - shellQuote(input.cwd), - '&&', - 'env', - envAssignments, - shellQuote(input.binaryPath), - ...input.args.map(shellQuote), - ].join(' '); - return `(${command}); __claude_teammate_exit=$?; printf '\\n__CLAUDE_TEAMMATE_EXIT__:%s\\n' "$__claude_teammate_exit"`; -} - interface ProviderModelListCommandResponse { schemaVersion?: number; providers?: Record< @@ -1640,12 +1587,6 @@ function hasAuthoritativeCodexLaunchCatalog( ); } -function getAnthropicFastModeDefault(): boolean { - return ( - ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true - ); -} - function resolveAnthropicSelectionFromFacts(params: { selectedModel?: string; limitContext?: boolean; @@ -1845,274 +1786,6 @@ function resolveRequestedLaunchModel(params: { return explicitModel ?? params.facts.defaultModel; } -function getTeamProviderLabel(providerId: TeamProviderId): string { - switch (providerId) { - case 'opencode': - return 'OpenCode'; - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'anthropic': - default: - return 'Anthropic'; - } -} - -function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null { - const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends; - switch (providerId) { - case 'opencode': - return null; - case 'gemini': - return runtimeConfig.gemini; - case 'codex': - return migrateProviderBackendId('codex', runtimeConfig.codex) ?? 'codex-native'; - case 'anthropic': - default: - return null; - } -} - -function isOpenCodeLegacyProvisioningRequest(request: { - providerId?: unknown; - members?: readonly { providerId?: unknown; provider?: unknown }[]; -}): boolean { - return ( - normalizeOptionalTeamProviderId(request.providerId) === 'opencode' || - (request.members ?? []).some( - (member) => - normalizeOptionalTeamProviderId(member.providerId) === 'opencode' || - normalizeOptionalTeamProviderId(member.provider) === 'opencode' - ) - ); -} - -function isPureOpenCodeProvisioningRequest(request: { - providerId?: unknown; - members?: readonly { providerId?: unknown; provider?: unknown }[]; -}): boolean { - if (!isOpenCodeLegacyProvisioningRequest(request)) { - return false; - } - - const rootProviderId = normalizeOptionalTeamProviderId(request.providerId); - if (rootProviderId && rootProviderId !== 'opencode') { - return false; - } - - return (request.members ?? []).every((member) => { - const memberProviderId = - normalizeOptionalTeamProviderId(member.providerId) ?? - normalizeOptionalTeamProviderId(member.provider); - return !memberProviderId || memberProviderId === 'opencode'; - }); -} - -export function getOpenCodeMixedProviderProvisioningError(): string { - return ( - 'This OpenCode mixed-team request is outside the current support scope. ' + - 'Supported mixed teams keep the lead on Anthropic or Codex. OpenCode-led mixed teams still remain blocked in this phase.' - ); -} - -export function getMixedLaunchFallbackRecoveryError(): string { - return 'This old mixed team is missing stable member metadata. Open Edit Team and save the roster once before launching.'; -} - -type TeamLaunchCompatibilityLevel = 'ready' | 'repairable' | 'unsafe'; -type TeamLaunchCompatibilityRosterSource = 'members-meta' | 'config' | 'inboxes' | 'missing'; -type TeamLaunchCompatibilityRepairAction = 'materialize-members-meta'; - -interface TeamLaunchCompatibilityReport { - level: TeamLaunchCompatibilityLevel; - rosterSource: TeamLaunchCompatibilityRosterSource; - members: TeamCreateRequest['members']; - warnings: string[]; - blockers: string[]; - repairAction?: TeamLaunchCompatibilityRepairAction; -} - -function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: { - providerId?: unknown; - members?: readonly { providerId?: unknown; provider?: unknown }[]; -}): void { - if (!isOpenCodeLegacyProvisioningRequest(request)) { - return; - } - const lanePlan = fromProvisioningMembers( - normalizeOptionalTeamProviderId(request.providerId), - (request.members ?? []).map((member, index) => ({ - name: `member-${index + 1}`, - providerId: - normalizeOptionalTeamProviderId(member.providerId) ?? - normalizeOptionalTeamProviderId(member.provider), - })) - ); - if (!lanePlan.ok) { - throw new Error(lanePlan.message || getOpenCodeMixedProviderProvisioningError()); - } - if (!isPureOpenCodeProvisioningRequest(request)) { - return; - } - throw new Error( - 'OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path. ' + - 'Use the gated OpenCode runtime adapter once production launch is enabled.' - ); -} - -function mergeProvisioningWarnings( - existing: string[] | undefined, - nextWarning: string | null -): string[] | undefined { - if (!nextWarning) return existing; - const merged = (existing ?? []).filter((warning) => warning !== nextWarning); - merged.push(nextWarning); - return merged.length > 0 ? merged : undefined; -} - -const DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD = 8; -const DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS = 20; - -function buildLargeDeterministicBootstrapWarning(memberCount: number): string | null { - if (memberCount <= DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD) { - return null; - } - return ( - `Large Codex team launch: ${memberCount} primary teammates will bootstrap in one runtime. ` + - `Launches above ${DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD} teammates can be slower and more likely to hit provider rate limits or bootstrap timeouts.` - ); -} - -function assertDeterministicBootstrapPrimaryMemberLimit(memberCount: number): void { - if (memberCount <= DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS) { - return; - } - throw new Error( - `Codex deterministic bootstrap currently supports up to ${DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS} primary teammates; this team has ${memberCount}. Reduce primary teammates or move extra OpenCode members to secondary lanes.` - ); -} - -function buildRuntimeLaunchWarning( - request: Pick< - TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' - >, - env: NodeJS.ProcessEnv, - options?: { - geminiRuntimeAuth?: GeminiRuntimeAuthState | null; - promptSize?: PromptSizeSummary | null; - expectedMembersCount?: number; - } -): string { - const providerId = resolveTeamProviderId(request.providerId); - const providerLabel = getTeamProviderLabel(providerId); - const modelLabel = request.model?.trim() || 'default'; - const effortLabel = request.effort ?? 'default'; - const fastLabel = - providerId === 'anthropic' - ? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}` - : providerId === 'codex' - ? `, fast ${request.fastMode ?? 'inherit:off'}` - : ''; - const backend = - migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || - getConfiguredRuntimeBackend(providerId); - const flags: string[] = []; - if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI'); - if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI'); - if (env.CLAUDE_CODE_ENTRY_PROVIDER) { - flags.push(`ENTRY_PROVIDER=${env.CLAUDE_CODE_ENTRY_PROVIDER}`); - } - if (env.CLAUDE_CODE_GEMINI_BACKEND) { - flags.push(`GEMINI_BACKEND=${env.CLAUDE_CODE_GEMINI_BACKEND}`); - } - if (env.CLAUDE_CODE_CODEX_BACKEND) { - flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`); - } - if (env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES === '1') { - flags.push('FORCE_PROCESS_TEAMMATES'); - } - const backendPart = backend ? `, backend ${backend}` : ''; - const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : ''; - const geminiAuth = options?.geminiRuntimeAuth; - const authPart = - providerId === 'gemini' && geminiAuth - ? `, auth ${geminiAuth.authMethod ?? 'none'}/${geminiAuth.resolvedBackend}` - : ''; - const promptSize = options?.promptSize; - const promptPart = promptSize - ? `, prompt ${promptSize.chars.toLocaleString('en-US')} chars/${promptSize.lines} lines` - : ''; - const membersPart = - typeof options?.expectedMembersCount === 'number' - ? `, members ${options.expectedMembersCount}` - : ''; - return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${fastLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`; -} - -function logRuntimeLaunchSnapshot( - teamName: string, - claudePath: string, - args: string[], - request: Pick< - TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' - >, - env: NodeJS.ProcessEnv, - options?: { - geminiRuntimeAuth?: GeminiRuntimeAuthState | null; - promptSize?: PromptSizeSummary | null; - expectedMembersCount?: number; - launchIdentity?: ProviderModelLaunchIdentity | null; - } -): void { - const providerId = resolveTeamProviderId(request.providerId); - const snapshot = { - providerId, - providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null, - model: request.model ?? null, - effort: request.effort ?? null, - fastMode: request.fastMode ?? null, - configuredBackend: - migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || - getConfiguredRuntimeBackend(providerId), - promptSize: options?.promptSize ?? null, - expectedMembersCount: options?.expectedMembersCount ?? null, - launchIdentity: options?.launchIdentity ?? null, - geminiRuntimeAuth: - providerId === 'gemini' - ? { - authenticated: options?.geminiRuntimeAuth?.authenticated ?? null, - authMethod: options?.geminiRuntimeAuth?.authMethod ?? null, - resolvedBackend: options?.geminiRuntimeAuth?.resolvedBackend ?? null, - projectId: options?.geminiRuntimeAuth?.projectId ?? null, - statusMessage: options?.geminiRuntimeAuth?.statusMessage ?? null, - } - : null, - env: { - CLAUDE_CODE_USE_GEMINI: env.CLAUDE_CODE_USE_GEMINI ?? null, - CLAUDE_CODE_USE_OPENAI: env.CLAUDE_CODE_USE_OPENAI ?? null, - CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null, - CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null, - CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null, - CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES ?? null, - CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null, - CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null, - }, - args, - claudePath, - }; - logger.info(`[${teamName}] Launch runtime snapshot ${JSON.stringify(snapshot)}`); -} - -function getPromptSizeSummary(prompt: string): PromptSizeSummary { - return { - chars: prompt.length, - lines: prompt.length === 0 ? 0 : prompt.split(/\r?\n/g).length, - }; -} - type TeamsBaseLocation = 'configured' | 'default'; type ValidConfigProbeResult = @@ -2442,67 +2115,6 @@ interface MemberLifecycleOperation { startedAtMs: number; } -function formatOpenCodeLaneTimingMs(value: number | null | undefined): string { - return typeof value === 'number' && Number.isFinite(value) - ? `${Math.max(0, Math.round(value))}ms` - : 'n/a'; -} - -function appendDiagnosticOnce(diagnostics: readonly string[], diagnostic: string | null): string[] { - if (!diagnostic || diagnostics.includes(diagnostic)) { - return [...diagnostics]; - } - return [...diagnostics, diagnostic]; -} - -function buildOpenCodeSecondaryLaneTimingDiagnostic( - lane: MixedSecondaryRuntimeLaneState -): string | null { - if ( - typeof lane.queuedAtMs !== 'number' || - typeof lane.launchStartedAtMs !== 'number' || - typeof lane.launchFinishedAtMs !== 'number' - ) { - return null; - } - return [ - 'OpenCode secondary lane timing:', - `member=${lane.member.name}`, - `queueWaitMs=${formatOpenCodeLaneTimingMs(lane.launchStartedAtMs - lane.queuedAtMs)}`, - `launchMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.launchStartedAtMs)}`, - `totalMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.queuedAtMs)}`, - ].join(' '); -} - -function createUnexpectedMixedSecondaryLaneFailureResult(input: { - runId: string; - teamName: string; - memberName: string; - message: string; -}): TeamRuntimeLaunchResult { - return { - runId: input.runId, - teamName: input.teamName, - launchPhase: 'finished', - teamLaunchState: 'partial_failure', - members: { - [input.memberName]: { - memberName: input.memberName, - providerId: 'opencode', - launchState: 'failed_to_start', - agentToolAccepted: false, - runtimeAlive: false, - bootstrapConfirmed: false, - hardFailure: true, - hardFailureReason: input.message, - diagnostics: [input.message], - }, - }, - warnings: [], - diagnostics: [input.message], - }; -} - type LeadActivityState = 'active' | 'idle' | 'offline'; type ProvisioningAuthSource = @@ -2522,32 +2134,6 @@ function isAnthropicDirectCredentialAuthSource(authSource: unknown): boolean { return isAnthropicApiKeyBackedAuthSource(authSource) || authSource === 'anthropic_auth_token'; } -function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - - try { - const url = new URL(trimmed); - return ( - (url.protocol === 'http:' || url.protocol === 'https:') && - !url.username && - !url.password && - url.hostname !== 'api.anthropic.com' && - url.hostname !== 'api-staging.anthropic.com' - ); - } catch { - return false; - } -} - -function hasAnthropicCompatibleAuthTokenEnv(env: NodeJS.ProcessEnv): boolean { - return Boolean( - isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL) && env.ANTHROPIC_AUTH_TOKEN?.trim() - ); -} - function buildAnthropicCrossProviderDirectAuthEnvPatch( env: NodeJS.ProcessEnv, authSource: ProvisioningAuthSource @@ -2610,110 +2196,16 @@ interface CrossProviderMemberArgsResult { usesAnthropicApiKeyHelper: boolean; } -interface PromptSizeSummary { - chars: number; - lines: number; -} - -const MEMBER_LAUNCH_GRACE_MS = 120_000; -const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; const OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS = { acquireTimeoutMs: 45_000, staleTimeoutMs: 60_000, retryIntervalMs: 50, } as const; -export function shouldWarnOnUnreadableMemberAuditConfig(params: { - nowMs: number; - lastWarnAt: number; - expectedMembers: readonly string[]; - memberSpawnStatuses: ReadonlyMap< - string, - Pick | undefined - >; -}): boolean { - const { nowMs, lastWarnAt, expectedMembers, memberSpawnStatuses } = params; - if (nowMs - lastWarnAt < MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS) { - return false; - } - return expectedMembers.some((memberName) => { - const current = memberSpawnStatuses.get(memberName); - if (!current?.agentToolAccepted || typeof current.firstSpawnAcceptedAt !== 'string') { - return false; - } - const acceptedAtMs = Date.parse(current.firstSpawnAcceptedAt); - return Number.isFinite(acceptedAtMs) && nowMs - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS; - }); -} - -export function shouldWarnOnMissingRegisteredMember(params: { - nowMs: number; - lastWarnAt: number; - graceExpired: boolean; -}): boolean { - const { nowMs, lastWarnAt, graceExpired } = params; - return graceExpired && nowMs - lastWarnAt >= MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS; -} - function nowIso(): string { return new Date().toISOString(); } -function parseOptionalIsoMs(value: string | undefined): number { - if (!value) return 0; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function deriveTaskActivityPauseAt(previous: MemberSpawnStatusEntry, fallbackAt: string): string { - const fallbackMs = parseOptionalIsoMs(fallbackAt); - const explicitEvidenceMs = Math.max( - parseOptionalIsoMs(previous.lastHeartbeatAt), - parseOptionalIsoMs(previous.livenessLastCheckedAt) - ); - const evidenceMs = - explicitEvidenceMs > 0 ? explicitEvidenceMs : parseOptionalIsoMs(previous.updatedAt); - if (evidenceMs <= 0 || fallbackMs <= 0) { - return fallbackAt; - } - const boundedEvidenceMs = Math.min(evidenceMs, fallbackMs); - const closeMs = Math.max( - boundedEvidenceMs, - Math.min(fallbackMs, boundedEvidenceMs + TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS) - ); - return new Date(closeMs).toISOString(); -} - -function deriveTaskActivityResumeAt( - previous: MemberSpawnStatusEntry, - evidenceAt: string, - fallbackAt: string -): string { - const fallbackMs = parseOptionalIsoMs(fallbackAt); - const evidenceMs = parseOptionalIsoMs(evidenceAt); - const previousUpdatedMs = parseOptionalIsoMs(previous.updatedAt); - if (evidenceMs <= 0 || fallbackMs <= 0) { - return fallbackAt; - } - if (previousUpdatedMs > 0 && evidenceMs < previousUpdatedMs) { - return fallbackAt; - } - return new Date(Math.min(evidenceMs, fallbackMs)).toISOString(); -} - -function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { - const updatedAt = nowIso(); - return { - status: 'offline', - launchState: 'starting', - agentToolAccepted: false, - runtimeAlive: false, - bootstrapConfirmed: false, - hardFailure: false, - updatedAt, - }; -} - interface LiveTeamAgentRuntimeMetadata { alive: boolean; backendType?: TeamAgentRuntimeBackendType; @@ -2736,372 +2228,12 @@ interface LiveTeamAgentRuntimeMetadata { diagnostics?: string[]; } -function isNeverSpawnedDuringLaunchReason(reason?: string): boolean { - return reason?.trim() === 'Teammate was never spawned during launch.'; -} - -function collectRuntimeLaunchFailureDiagnostics( - result: TeamRuntimeLaunchResult, - memberName: string -): string[] { - const member = result.members[memberName]; - return [...(member?.diagnostics ?? []), member?.hardFailureReason, ...result.diagnostics].filter( - (value): value is string => typeof value === 'string' && value.trim().length > 0 - ); -} - -function collectOpenCodeSecondaryLaneFailureDiagnostics( - result: TeamRuntimeLaunchResult, - memberName: string, - prefixDiagnostics: readonly string[] -): string[] { - const diagnostics = [ - ...prefixDiagnostics, - ...collectRuntimeLaunchFailureDiagnostics(result, memberName), - ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); - return diagnostics.length > 0 ? diagnostics : ['OpenCode bridge reported member launch failure']; -} - -function isReconciliableOpenCodeUnknownOutcome(diagnostics: readonly string[]): boolean { - return diagnostics.some((diagnostic) => - /outcome must be reconciled before retry/i.test(diagnostic) - ); -} - -function isDefinitiveOpenCodePreLaunchFailure( - result: TeamRuntimeLaunchResult, - memberName: string -): boolean { - const member = result.members[memberName]; - if (!member) { - return false; - } - const hardFailed = member.launchState === 'failed_to_start' || member.hardFailure === true; - if (!hardFailed) { - return false; - } - const runtimeMaterialized = - member.agentToolAccepted || - member.runtimeAlive || - member.bootstrapConfirmed || - (typeof member.sessionId === 'string' && member.sessionId.trim().length > 0) || - (typeof member.runtimePid === 'number' && - Number.isFinite(member.runtimePid) && - member.runtimePid > 0); - if (runtimeMaterialized) { - return false; - } - return !isReconciliableOpenCodeUnknownOutcome( - collectRuntimeLaunchFailureDiagnostics(result, memberName) - ); -} - -const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC = - 'opencode_bootstrap_pending_after_materialized_session'; -const OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC = - 'OpenCode app-managed bootstrap evidence is pending after materialized session.'; - -function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean { - if (typeof sessionId !== 'string') { - return false; - } - const trimmed = sessionId.trim(); - return trimmed.length > 0 && !trimmed.startsWith('failed:'); -} - -function hasMaterializedOpenCodeRuntimeForBootstrap( - member: TeamRuntimeMemberLaunchEvidence | undefined -): member is TeamRuntimeMemberLaunchEvidence { - if (!member) { - return false; - } - if (isMaterializedOpenCodeSessionId(member.sessionId)) { - return true; - } - return ( - hasOpenCodeRuntimeLivenessMarker(member) && - typeof member.runtimePid === 'number' && - Number.isFinite(member.runtimePid) && - member.runtimePid > 0 - ); -} - -function hasRecoverableOpenCodeBootstrapDiagnostic(diagnostics: readonly string[]): boolean { - const text = diagnostics.join('\n').toLowerCase(); - if (!text) { - return false; - } - if (hasRealOpenCodeFailureDiagnostic(text)) { - return false; - } - return ( - text.includes('runtime_bootstrap_checkin') || - text.includes('member_briefing') || - text.includes('bootstrap mcp') || - text.includes('member_session_recorded') || - text.includes('not connected') || - text.includes('mcp not connected') || - text.includes('member_launch_reconcile_pending') || - text.includes('member_launch_preview_timeout') - ); -} - -function isRecoverableOpenCodeBootstrapPendingLaunchResult( - result: TeamRuntimeLaunchResult, - memberName: string -): boolean { - const member = result.members[memberName]; - if (!hasMaterializedOpenCodeRuntimeForBootstrap(member)) { - return false; - } - if (member.bootstrapConfirmed || member.launchState === 'confirmed_alive') { - return false; - } - if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { - return false; - } - return hasRecoverableOpenCodeBootstrapDiagnostic( - collectRuntimeLaunchFailureDiagnostics(result, memberName) - ); -} - -function normalizeRecoverableOpenCodeBootstrapPendingLaunchResult( - result: TeamRuntimeLaunchResult, - memberName: string, - diagnostics: readonly string[] -): TeamRuntimeLaunchResult { - const member = result.members[memberName]; - if (!member) { - return result; - } - const memberDiagnostics = Array.from( - new Set([ - ...(member.diagnostics ?? []), - OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC, - isExplicitLegacyOpenCodeBootstrap(member) - ? 'OpenCode runtime session materialized; waiting for runtime_bootstrap_checkin.' - : OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC, - ...diagnostics, - ]) - ); - const normalizedMember: TeamRuntimeMemberLaunchEvidence = { - ...member, - launchState: 'runtime_pending_bootstrap', - agentToolAccepted: true, - runtimeAlive: true, - bootstrapConfirmed: false, - hardFailure: false, - hardFailureReason: undefined, - pendingPermissionRequestIds: undefined, - livenessKind: - member.livenessKind === 'confirmed_bootstrap' - ? 'runtime_process' - : (member.livenessKind ?? 'runtime_process'), - runtimeDiagnostic: - member.runtimeDiagnostic ?? - 'OpenCode runtime process detected; waiting for bootstrap check-in.', - runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'info', - diagnostics: memberDiagnostics, - }; - const members = { - ...result.members, - [memberName]: normalizedMember, - }; - const teamLaunchState = summarizeRuntimeLaunchResultMembers(members); - return { - ...result, - launchPhase: teamLaunchState === 'clean_success' ? result.launchPhase : 'active', - teamLaunchState, - members, - diagnostics: Array.from(new Set([...result.diagnostics, ...memberDiagnostics])), - }; -} - -const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC = - 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'; - -function buildOpenCodeUncommittedBootstrapDiagnostic(storage: { - manifestEntryCount: number | null; - manifestUpdatedAt: string | null; - fileNames: string[]; -}): string[] { - return [ - OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC, - `OpenCode lane manifest entries: ${storage.manifestEntryCount ?? 0}`, - ...(storage.manifestUpdatedAt - ? [`OpenCode lane manifest updated at: ${storage.manifestUpdatedAt}`] - : []), - storage.fileNames.length > 0 - ? `OpenCode lane files: ${storage.fileNames.slice(0, 8).join(', ')}` - : 'OpenCode lane files: none', - ]; -} - -function downgradeUncommittedOpenCodeBootstrapEvidence( - evidence: TeamRuntimeMemberLaunchEvidence, - diagnostics: readonly string[] -): TeamRuntimeMemberLaunchEvidence { - const hasRuntimeHandle = hasOpenCodeRuntimeHandle(evidence); - return { - ...evidence, - launchState: hasRuntimeHandle ? 'runtime_pending_bootstrap' : 'starting', - agentToolAccepted: hasRuntimeHandle, - runtimeAlive: false, - bootstrapConfirmed: false, - hardFailure: false, - hardFailureReason: undefined, - livenessKind: hasRuntimeHandle - ? evidence.livenessKind === 'confirmed_bootstrap' - ? 'runtime_process_candidate' - : (evidence.livenessKind ?? 'runtime_process_candidate') - : 'registered_only', - runtimeDiagnostic: hasRuntimeHandle - ? 'OpenCode runtime handle is present, but bootstrap evidence was not committed.' - : 'OpenCode bootstrap confirmation was not committed to lane runtime evidence.', - runtimeDiagnosticSeverity: 'warning', - diagnostics: Array.from(new Set([...evidence.diagnostics, ...diagnostics])), - }; -} - -function promoteCommittedOpenCodeAppManagedBootstrapEvidence( - evidence: TeamRuntimeMemberLaunchEvidence -): TeamRuntimeMemberLaunchEvidence { - return { - ...evidence, - launchState: 'confirmed_alive', - agentToolAccepted: true, - runtimeAlive: true, - bootstrapConfirmed: true, - hardFailure: false, - hardFailureReason: undefined, - livenessKind: 'confirmed_bootstrap', - runtimeDiagnostic: - 'OpenCode app-managed bootstrap evidence was committed and read back by the desktop app.', - runtimeDiagnosticSeverity: 'info', - diagnostics: appendDiagnosticOnce( - evidence.diagnostics, - 'OpenCode app-managed bootstrap evidence committed and read back.' - ), - }; -} - -function summarizeRuntimeLaunchResultMembers( - members: Record -): TeamLaunchAggregateState { - const values = Object.values(members); - if ( - values.some((member) => member.launchState === 'failed_to_start' || member.hardFailure === true) - ) { - return 'partial_failure'; - } - if (values.length > 0 && values.every((member) => member.launchState === 'confirmed_alive')) { - return 'clean_success'; - } - return 'partial_pending'; -} - -function hasOpenCodeRuntimeHandle( - value: - | Pick - | Pick - | undefined -): boolean { - if (!value) { - return false; - } - const runtimePid = - typeof value.runtimePid === 'number' && - Number.isFinite(value.runtimePid) && - value.runtimePid > 0; - const runtimeSessionId = (value as { runtimeSessionId?: unknown }).runtimeSessionId; - const runtimeEvidenceSessionId = (value as { sessionId?: unknown }).sessionId; - const sessionId = - (typeof runtimeSessionId === 'string' && runtimeSessionId.trim().length > 0) || - (typeof runtimeEvidenceSessionId === 'string' && runtimeEvidenceSessionId.trim().length > 0); - return runtimePid || sessionId; -} - -function hasOpenCodeRuntimeLivenessMarker( - value: Pick | undefined -): boolean { - return ( - value?.livenessKind === 'runtime_process' || - value?.livenessKind === 'runtime_process_candidate' || - value?.livenessKind === 'permission_blocked' - ); -} - -function hasOpenCodeRuntimeEntryHandle( - value: - | Pick - | undefined - | null -): boolean { - if (!value) { - return false; - } - const pid = typeof value.pid === 'number' && Number.isFinite(value.pid) && value.pid > 0; - const runtimePid = - typeof value.runtimePid === 'number' && - Number.isFinite(value.runtimePid) && - value.runtimePid > 0; - const runtimeSessionId = - typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0; - return pid || runtimePid || runtimeSessionId || hasOpenCodeRuntimeLivenessMarker(value); -} - -function isRecoverablePersistedOpenCodeRuntimeCandidate( - member: PersistedTeamLaunchMemberState | undefined | null -): boolean { - if (!member || member.skippedForLaunch) { - return false; - } - if ( - member.providerId !== 'opencode' || - member.laneKind !== 'secondary' || - member.laneOwnerProviderId !== 'opencode' || - typeof member.laneId !== 'string' || - member.laneId.trim().length === 0 - ) { - return false; - } - const hasPendingPermission = (member.pendingPermissionRequestIds?.length ?? 0) > 0; - return ( - member.agentToolAccepted === true && (hasOpenCodeRuntimeHandle(member) || hasPendingPermission) - ); -} - -function isPersistedOpenCodeSecondaryLaneMember( - member: PersistedTeamLaunchMemberState | undefined | null -): boolean { - return ( - member?.providerId === 'opencode' && - member.laneKind === 'secondary' && - member.laneOwnerProviderId === 'opencode' && - typeof member.laneId === 'string' && - member.laneId.trim().length > 0 - ); -} - const OPENCODE_BOOTSTRAP_CHECKIN_RETRY_SENT_PREFIX = 'opencode_bootstrap_checkin_retry_prompt_sent'; -const OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC = - 'OpenCode app-managed bootstrap evidence did not commit within 5 min.'; function getOpenCodeBootstrapCheckinRetryMarker(runId: string, runtimeSessionId: string): string { return `${OPENCODE_BOOTSTRAP_CHECKIN_RETRY_SENT_PREFIX}:${runId}:${runtimeSessionId}`; } -function isExplicitLegacyOpenCodeBootstrap( - value: - | { - bootstrapMode?: 'model_tool_checkin' | 'app_managed_context'; - } - | undefined - | null -): boolean { - return value?.bootstrapMode === 'model_tool_checkin'; -} - function resolveOpenCodeSecondaryLaneMemberEvidence( lane: MixedSecondaryRuntimeLaneState | undefined, memberName: string @@ -3117,348 +2249,6 @@ function resolveOpenCodeSecondaryLaneMemberEvidence( ); } -const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN = - /\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i; - -function normalizeIsoTimestamp(value: unknown): string | null { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const parsed = Date.parse(trimmed); - return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null; -} - -function selectEarliestIsoTimestamp(values: readonly unknown[]): string | undefined { - let selected: { value: string; timeMs: number } | null = null; - for (const value of values) { - const normalized = normalizeIsoTimestamp(value); - if (!normalized) { - continue; - } - const timeMs = Date.parse(normalized); - if (!selected || timeMs < selected.timeMs) { - selected = { value: normalized, timeMs }; - } - } - return selected?.value; -} - -function extractOpenCodeMemberSessionRecordedAt( - diagnostics: readonly string[] | undefined -): string[] { - return (diagnostics ?? []).flatMap((diagnostic) => { - const match = OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN.exec(diagnostic); - return match?.[1] ? [match[1]] : []; - }); -} - -function resolveOpenCodeBootstrapAcceptedAt( - member: Pick -): string | undefined { - return selectEarliestIsoTimestamp([ - member.firstSpawnAcceptedAt, - ...extractOpenCodeMemberSessionRecordedAt(member.diagnostics), - ]); -} - -function hasOpenCodeSecondaryFatalBootstrapDiagnostic( - member: Pick< - PersistedTeamLaunchMemberState, - 'diagnostics' | 'runtimeDiagnostic' | 'hardFailureReason' - > -): boolean { - const text = [member.runtimeDiagnostic, member.hardFailureReason, ...(member.diagnostics ?? [])] - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .join('\n') - .toLowerCase(); - return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text); -} - -function selectOpenCodeSecondaryBootstrapStallDiagnostic( - values: readonly unknown[] -): string | null { - const normalizedValues = values - .filter((value): value is string => typeof value === 'string') - .map((value) => normalizeOpenCodePersistedFailureReason(value)) - .filter((value): value is string => typeof value === 'string' && value.length > 0); - - const runtimeCheckinDiagnostic = normalizedValues.find((value) => - value.toLowerCase().includes('runtime_bootstrap_checkin') - ); - if (runtimeCheckinDiagnostic) { - return runtimeCheckinDiagnostic; - } - - const memberBriefingDiagnostic = normalizedValues.find((value) => - value.toLowerCase().includes('member_briefing') - ); - if (memberBriefingDiagnostic) { - return `${memberBriefingDiagnostic}; runtime_bootstrap_checkin did not complete after 5 min.`; - } - - return null; -} - -function getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted( - member: PersistedTeamLaunchMemberState -): string { - if (!isExplicitLegacyOpenCodeBootstrap(member)) { - return OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC; - } - - const selected = selectOpenCodeSecondaryBootstrapStallDiagnostic([ - member.runtimeDiagnostic, - ...(member.diagnostics ?? []), - member.hardFailureReason, - ]); - if (selected) { - return selected; - } - - return 'OpenCode bootstrap did not complete runtime_bootstrap_checkin after 5 min.'; -} - -function shouldMarkPersistedOpenCodeBootstrapStalled( - member: PersistedTeamLaunchMemberState, - nowMs: number -): boolean { - if (!isPersistedOpenCodeSecondaryLaneMember(member)) { - return false; - } - if ( - member.launchState !== 'runtime_pending_bootstrap' || - member.bootstrapConfirmed === true || - member.hardFailure === true || - member.skippedForLaunch === true || - (member.pendingPermissionRequestIds?.length ?? 0) > 0 - ) { - return false; - } - if (hasOpenCodeSecondaryFatalBootstrapDiagnostic(member)) { - return false; - } - const acceptedAt = resolveOpenCodeBootstrapAcceptedAt(member); - const acceptedAtMs = acceptedAt ? Date.parse(acceptedAt) : NaN; - if (!Number.isFinite(acceptedAtMs) || nowMs - acceptedAtMs < MEMBER_BOOTSTRAP_STALL_MS) { - return false; - } - return ( - hasOpenCodeRuntimeHandle(member) || - hasOpenCodeRuntimeLivenessMarker(member) || - hasRecoverableOpenCodeBootstrapDiagnostic( - [member.runtimeDiagnostic, ...(member.diagnostics ?? [])].filter( - (value): value is string => typeof value === 'string' - ) - ) - ); -} - -function namesMatchCaseInsensitive(left: string, right: string): boolean { - return left.trim().toLowerCase() === right.trim().toLowerCase(); -} - -function isOpenCodeOverlayMemberRemoved( - metaMembers: readonly { name?: string; removedAt?: unknown }[], - memberName: string -): boolean { - return metaMembers.some( - (member) => - typeof member.name === 'string' && - namesMatchCaseInsensitive(member.name, memberName) && - member.removedAt != null - ); -} - -function hasStaleOpenCodeSecondaryLaunchDiagnostic( - member: PersistedTeamLaunchMemberState -): boolean { - return hasStaleOpenCodeDiagnostics(getOpenCodeLaunchDiagnosticValues(member)); -} - -function hasRealOpenCodeLaunchDiagnostic(member: PersistedTeamLaunchMemberState): boolean { - const text = getOpenCodeLaunchDiagnosticValues(member) - .filter((value): value is string => typeof value === 'string') - .join('\n') - .toLowerCase(); - return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text); -} - -function getOpenCodeLaunchDiagnosticValues( - member: PersistedTeamLaunchMemberState -): readonly unknown[] { - return [member.hardFailureReason, member.runtimeDiagnostic, ...(member.diagnostics ?? [])]; -} - -function hasStaleOpenCodeDiagnostics(values: readonly unknown[] | undefined): boolean { - const text = (values ?? []) - .filter((value): value is string => typeof value === 'string') - .join('\n') - .toLowerCase(); - if (!text) { - return false; - } - if (hasRealOpenCodeFailureDiagnostic(text)) { - return false; - } - return ( - text.includes('no lane runtime evidence') || - text.includes('no runtime evidence') || - text.includes('runtime evidence was not committed') || - text.includes('no lane runtime evidence was committed') || - text.includes('registered runtime metadata without live process') || - text.includes('member has persisted runtime metadata only') || - text.includes('opencode bridge reported member launch failure') || - text.includes('file lock timeout') || - text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase()) - ); -} - -function isFileLockTimeoutError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.toLowerCase().includes('file lock timeout'); -} - -function hasRealOpenCodeFailureDiagnostic(text: string): boolean { - return ( - /\bauth(?:entication|orization)?\b/.test(text) || - text.includes('api key') || - text.includes('unauthorized') || - text.includes('forbidden') || - text.includes('invalid_request') || - text.includes('model not found') || - text.includes('not found in live opencode catalog') || - text.includes('provider unavailable') || - text.includes('quota') || - text.includes('credits') || - text.includes('max_tokens') || - text.includes('rate limit') || - text.includes('member removed') || - text.includes('session conflict') || - text.includes('run tombstoned') || - text.includes('stop requested') || - text.includes('relaunch started') - ); -} - -const OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON = - 'OpenCode bridge reported member launch failure'; -const OPEN_CODE_SECRET_FLAG_PATTERN = - /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; -const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Z0-9._~+/=-]+/gi; -const OPEN_CODE_SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g; -const OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS = 12_000; - -function normalizeOpenCodePersistedFailureReason(value: string | undefined): string | undefined { - const trimmed = value?.replace(/\s+/g, ' ').trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]') - .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]') - .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]'); -} - -function redactOpenCodeAppManagedContextText(value: string): string { - return value - .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]') - .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]') - .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]'); -} - -function boundOpenCodeAppManagedBriefingText(value: string): string { - const normalized = redactOpenCodeAppManagedContextText(value.replace(/\r\n/g, '\n')).trim(); - if (normalized.length <= OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS) { - return normalized; - } - return `${normalized.slice(0, OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS)}\n[truncated app-managed briefing]`; -} - -function isGenericOpenCodePersistedFailureReason(value: string | undefined): boolean { - const normalized = normalizeOpenCodePersistedFailureReason(value); - return ( - normalized === OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON || - normalized?.startsWith(`${OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON}:`) === true || - normalized?.startsWith('OpenCode secondary lane timing:') === true || - normalized?.startsWith( - 'OpenCode bridge reported ready without all required durable checkpoints:' - ) === true || - normalized?.startsWith( - 'OpenCode bridge reported ready before all expected members were confirmed:' - ) === true || - normalized?.startsWith( - 'OpenCode bootstrap MCP did not complete required tools before assistant response:' - ) === true || - normalized?.startsWith('info:opencode_launch_member_timing:') === true || - normalized?.startsWith('info:opencode_launch_total_timing:') === true - ); -} - -function selectOpenCodePersistedFailureReasonFromDiagnostics( - member: PersistedTeamLaunchMemberState -): string | undefined { - if (!isPersistedOpenCodeSecondaryLaneMember(member)) { - return undefined; - } - if (member.launchState !== 'failed_to_start' || member.hardFailure !== true) { - return undefined; - } - if (!isGenericOpenCodePersistedFailureReason(member.hardFailureReason)) { - return undefined; - } - for (const value of member.diagnostics ?? []) { - const normalized = normalizeOpenCodePersistedFailureReason(value); - if (!normalized || isGenericOpenCodePersistedFailureReason(normalized)) { - continue; - } - return normalized; - } - return undefined; -} - -function promoteOpenCodePersistedFailureReasonsFromDiagnostics( - snapshot: PersistedTeamLaunchSnapshot | null -): PersistedTeamLaunchSnapshot | null { - if (!snapshot) { - return null; - } - let changed = false; - const members: Record = { ...snapshot.members }; - for (const [memberName, member] of Object.entries(snapshot.members)) { - const promotedReason = selectOpenCodePersistedFailureReasonFromDiagnostics(member); - if (!promotedReason || promotedReason === member.hardFailureReason) { - continue; - } - members[memberName] = { - ...member, - hardFailureReason: promotedReason, - runtimeDiagnostic: - member.runtimeDiagnostic && - !isGenericOpenCodePersistedFailureReason(member.runtimeDiagnostic) - ? member.runtimeDiagnostic - : promotedReason, - runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'error', - }; - changed = true; - } - if (!changed) { - return snapshot; - } - return createPersistedLaunchSnapshot({ - teamName: snapshot.teamName, - expectedMembers: snapshot.expectedMembers, - bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers, - leadSessionId: snapshot.leadSessionId, - launchPhase: snapshot.launchPhase, - members, - updatedAt: nowIso(), - }); -} - function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: { current: PersistedTeamLaunchMemberState; previous: PersistedTeamLaunchMemberState | null; @@ -3522,155 +2312,6 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: { }; } -function filterStaleOpenCodeOverlayDiagnostics(values: readonly string[] | undefined): string[] { - return (values ?? []).filter((value) => !hasStaleOpenCodeDiagnostics([value])); -} - -function isRecoverablePersistedOpenCodeTerminalRuntimeCandidate( - member: PersistedTeamLaunchMemberState | undefined | null -): boolean { - return ( - isRecoverablePersistedOpenCodeRuntimeCandidate(member) && - member?.launchState === 'failed_to_start' && - member.hardFailure === true && - hasOpenCodeRuntimeHandle(member) - ); -} - -function isRecoverableOpenCodeRuntimeEvidence( - evidence: TeamRuntimeMemberLaunchEvidence | undefined | null -): evidence is TeamRuntimeMemberLaunchEvidence { - if (!evidence) { - return false; - } - return ( - evidence.runtimeAlive === true || - evidence.bootstrapConfirmed === true || - (evidence.pendingPermissionRequestIds?.length ?? 0) > 0 || - hasOpenCodeRuntimeHandle(evidence) || - (evidence.agentToolAccepted === true && hasOpenCodeRuntimeLivenessMarker(evidence)) - ); -} - -function isLaunchGraceWindowFailureReason(reason?: string): boolean { - return reason?.trim() === 'Teammate did not join within the launch grace window.'; -} - -function isConfigRegistrationFailureReason(reason?: string): boolean { - return ( - reason?.trim() === - 'Teammate was not registered in config.json during launch. Persistent spawn failed.' - ); -} - -function isOpenCodeBridgeLaunchFailureReason(reason?: string): boolean { - return reason?.trim() === 'OpenCode bridge reported member launch failure'; -} - -function isRegisteredRuntimeMetadataFailureReason(reason?: string): boolean { - return reason?.trim() === 'registered runtime metadata without live process'; -} - -function isProcessTableUnavailableFailureReason(reason?: string): boolean { - const text = reason?.trim(); - if (!text || !mentionsProcessTableUnavailable(text)) { - return false; - } - return ( - /^process table (?:is )?unavailable$/i.test(text) || - /^runtime pid could not be verified because process table (?:is )?unavailable$/i.test(text) - ); -} - -function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null { - const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim()); - const baseReason = match?.[1]?.trim(); - return baseReason && baseReason.length > 0 ? baseReason : null; -} - -function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean { - return ( - isNeverSpawnedDuringLaunchReason(reason) || - isLaunchGraceWindowFailureReason(reason) || - isConfigRegistrationFailureReason(reason) || - isRegisteredRuntimeMetadataFailureReason(reason) || - isOpenCodeBridgeLaunchFailureReason(reason) || - isBootstrapMcpResourceReadFailureReason(reason) || - isBootstrapCheckInTimeoutFailureReason(reason) || - isBootstrapInstructionPromptFailureReason(reason) || - isLaunchCleanupBootstrapIncompleteFailureReason(reason) - ); -} - -function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean { - const text = reason?.trim().toLowerCase() ?? ''; - return ( - text.includes('resources/read failed') && - text.includes('member_briefing') && - (text.includes('method not found') || text.includes('mcp error')) - ); -} - -function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean { - return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.'; -} - -function isBootstrapInstructionPromptFailureReason(reason?: string): boolean { - return typeof reason === 'string' && isBootstrapInstructionPrompt(reason); -} - -function isLaunchCleanupBootstrapIncompleteFailureReason(reason?: string): boolean { - const text = reason?.trim(); - if (!text) { - return false; - } - if ( - text === 'Launch ended before teammate bootstrap completed.' || - text === 'Deterministic bootstrap failed before teammate check-in.' - ) { - return true; - } - return ( - text.startsWith('Launch ended before teammate bootstrap completed. ') && - text.includes('Runtime process was alive after bootstrap failure') - ); -} - -function isBootstrapMemberEvidenceCurrentForMember( - current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string }, - bootstrapMember: Pick< - PersistedTeamLaunchMemberState, - 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'lastRuntimeAliveAt' | 'lastEvaluatedAt' - >, - evidenceKind: 'acceptance' | 'confirmation' -): boolean { - const bootstrapFirstSpawnAcceptedMs = Date.parse(bootstrapMember.firstSpawnAcceptedAt ?? ''); - const bootstrapLastEvaluatedMs = Date.parse(bootstrapMember.lastEvaluatedAt ?? ''); - const hasDurableBootstrapSpawnAcceptedAt = - Number.isFinite(bootstrapFirstSpawnAcceptedMs) && - (!Number.isFinite(bootstrapLastEvaluatedMs) || - bootstrapFirstSpawnAcceptedMs <= bootstrapLastEvaluatedMs); - const evidenceAt = - evidenceKind === 'confirmation' - ? (bootstrapMember.lastHeartbeatAt ?? - bootstrapMember.lastRuntimeAliveAt ?? - bootstrapMember.lastEvaluatedAt) - : hasDurableBootstrapSpawnAcceptedAt - ? bootstrapMember.firstSpawnAcceptedAt - : bootstrapMember.lastEvaluatedAt; - const evidenceMs = Date.parse(evidenceAt ?? ''); - if (!Number.isFinite(evidenceMs)) { - return false; - } - const firstSpawnAcceptedMs = Date.parse(current.firstSpawnAcceptedAt ?? ''); - const lastEvaluatedMs = Date.parse(current.lastEvaluatedAt ?? ''); - const hasDurableSpawnBoundary = - Number.isFinite(firstSpawnAcceptedMs) && - (!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs); - const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN; - return !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs; -} - function isTmuxNoServerRunningError(error: unknown): boolean { const text = error instanceof Error ? error.message : String(error ?? ''); return ( @@ -3679,109 +2320,6 @@ function isTmuxNoServerRunningError(error: unknown): boolean { ); } -function isAutoClearableLaunchFailureReason(reason?: string): boolean { - const text = reason?.trim(); - if (!text) { - return false; - } - const baseReason = stripProcessTableUnavailableDiagnosticSuffix(text); - return ( - isBaseAutoClearableLaunchFailureReason(text) || - isProcessTableUnavailableFailureReason(text) || - (baseReason != null && isBaseAutoClearableLaunchFailureReason(baseReason)) - ); -} - -function summarizeMemberSpawnStatusRecord( - expectedMembers: readonly string[], - statuses: Record -): PersistedTeamLaunchSummary { - let confirmedCount = 0; - let pendingCount = 0; - let failedCount = 0; - let skippedCount = 0; - let runtimeAlivePendingCount = 0; - let shellOnlyPendingCount = 0; - let runtimeProcessPendingCount = 0; - let runtimeCandidatePendingCount = 0; - let noRuntimePendingCount = 0; - let permissionPendingCount = 0; - const memberNames = Array.from(new Set([...expectedMembers, ...Object.keys(statuses)])); - - for (const memberName of memberNames) { - const entry = statuses[memberName]; - if (!entry) { - pendingCount += 1; - continue; - } - if (entry.launchState === 'confirmed_alive') { - confirmedCount += 1; - continue; - } - if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { - skippedCount += 1; - continue; - } - if (entry.launchState === 'failed_to_start') { - failedCount += 1; - continue; - } - pendingCount += 1; - if (entry.runtimeAlive) { - runtimeAlivePendingCount += 1; - } - if (entry.launchState === 'runtime_pending_permission') { - permissionPendingCount += 1; - } - if (entry.livenessKind === 'shell_only') { - shellOnlyPendingCount += 1; - } else if (entry.livenessKind === 'runtime_process') { - runtimeProcessPendingCount += 1; - } else if (entry.livenessKind === 'runtime_process_candidate') { - runtimeCandidatePendingCount += 1; - } else if ( - entry.livenessKind === 'not_found' || - entry.livenessKind === 'stale_metadata' || - entry.livenessKind === 'registered_only' - ) { - noRuntimePendingCount += 1; - } - } - - return { - confirmedCount, - pendingCount, - failedCount, - skippedCount, - runtimeAlivePendingCount, - shellOnlyPendingCount, - runtimeProcessPendingCount, - runtimeCandidatePendingCount, - noRuntimePendingCount, - permissionPendingCount, - }; -} - -function buildRestartStillRunningReason(memberName: string): string { - return ( - `Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` + - `to be active. The requested settings may not have been applied.` - ); -} - -function buildRestartDuplicateUnconfirmedReason(memberName: string, rawReason?: string): string { - const suffix = rawReason?.trim() - ? ` Agent returned duplicate_skipped with unrecognized reason "${rawReason.trim()}".` - : ' Agent returned duplicate_skipped without a reason.'; - return ( - `Restart for teammate "${memberName}" could not be confirmed and may not have applied.` + suffix - ); -} - -function buildRestartGraceTimeoutReason(memberName: string): string { - return `Teammate "${memberName}" did not rejoin within the restart grace window.`; -} - interface PendingMemberRestartContext { requestedAt: string; desired: Pick< @@ -3802,30 +2340,6 @@ function normalizeTeamAgentRuntimeBackendType( return normalized ? 'process' : undefined; } -function matchesMemberNameOrBase(candidateName: string, memberName: string): boolean { - if (candidateName === memberName) { - return true; - } - const parsed = parseNumericSuffixName(candidateName); - return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName; -} - -function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean { - return ( - matchesMemberNameOrBase(leftName, rightName) || matchesMemberNameOrBase(rightName, leftName) - ); -} - -function matchesObservedMemberNameForExpected(observedName: string, expectedName: string): boolean { - return matchesMemberNameOrBase(observedName, expectedName); -} - -function matchesExactTeamMemberName(candidateName: string, memberName: string): boolean { - const left = candidateName.trim().toLowerCase(); - const right = memberName.trim().toLowerCase(); - return left.length > 0 && left === right; -} - interface MemberSpawnInboxCursor { timestamp: string; messageId: string; @@ -3952,32 +2466,6 @@ export function shouldAcceptDeterministicBootstrapEvent(params: { return { accept: true, nextSeq: params.lastSeq }; } -function deriveMemberLaunchState(entry: { - agentToolAccepted?: boolean; - runtimeAlive?: boolean; - bootstrapConfirmed?: boolean; - hardFailure?: boolean; - skippedForLaunch?: boolean; - pendingPermissionRequestIds?: string[]; -}): MemberLaunchState { - if (entry.skippedForLaunch) { - return 'skipped_for_launch'; - } - if (entry.hardFailure) { - return 'failed_to_start'; - } - if (entry.bootstrapConfirmed) { - return 'confirmed_alive'; - } - if ((entry.pendingPermissionRequestIds?.length ?? 0) > 0) { - return 'runtime_pending_permission'; - } - if (entry.runtimeAlive || entry.agentToolAccepted) { - return 'runtime_pending_bootstrap'; - } - return 'starting'; -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -4263,202 +2751,6 @@ function updateProgress( return run.progress; } -function buildLaunchDiagnosticsFromRun( - run: ProvisioningRun -): TeamLaunchDiagnosticItem[] | undefined { - const memberSpawnStatuses = run.memberSpawnStatuses; - if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) { - return undefined; - } - const observedAt = nowIso(); - const items: TeamLaunchDiagnosticItem[] = []; - for (const [memberName, entry] of memberSpawnStatuses.entries()) { - if (entry.launchState === 'confirmed_alive') { - items.push({ - id: `${memberName}:bootstrap_confirmed`, - memberName, - severity: 'info', - code: 'bootstrap_confirmed', - label: `${memberName} - bootstrap confirmed`, - observedAt, - }); - continue; - } - if (entry.launchState === 'failed_to_start') { - items.push({ - id: `${memberName}:bootstrap_stalled`, - memberName, - severity: 'error', - code: 'bootstrap_stalled', - label: `${memberName} - failed to start`, - detail: entry.hardFailureReason ?? entry.error, - observedAt, - }); - continue; - } - if (entry.launchState === 'runtime_pending_permission') { - items.push({ - id: `${memberName}:permission_pending`, - memberName, - severity: 'warning', - code: 'permission_pending', - label: `${memberName} - awaiting permission`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - continue; - } - if (entry.bootstrapStalled === true) { - items.push({ - id: `${memberName}:bootstrap_stalled`, - memberName, - severity: 'warning', - code: 'bootstrap_stalled', - label: `${memberName} - bootstrap stalled`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - continue; - } - if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) { - items.push({ - id: `${memberName}:process_table_unavailable`, - memberName, - severity: 'warning', - code: 'process_table_unavailable', - label: `${memberName} - process table unavailable`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - continue; - } - if (entry.livenessKind === 'shell_only') { - items.push({ - id: `${memberName}:tmux_shell_only`, - memberName, - severity: 'warning', - code: 'tmux_shell_only', - label: `${memberName} - shell only`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - continue; - } - if (entry.livenessKind === 'runtime_process_candidate') { - items.push({ - id: `${memberName}:runtime_process_candidate`, - memberName, - severity: 'warning', - code: 'runtime_process_candidate', - label: `${memberName} - bootstrap unconfirmed`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - continue; - } - if (entry.livenessKind === 'runtime_process') { - items.push({ - id: `${memberName}:runtime_process_detected`, - memberName, - severity: 'info', - code: 'runtime_process_detected', - label: `${memberName} - waiting for bootstrap`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - continue; - } - if ( - entry.livenessKind === 'registered_only' || - entry.livenessKind === 'stale_metadata' || - entry.livenessKind === 'not_found' - ) { - items.push({ - id: `${memberName}:runtime_not_found`, - memberName, - severity: 'warning', - code: 'runtime_not_found', - label: `${memberName} - waiting for runtime`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - continue; - } - if (entry.agentToolAccepted) { - items.push({ - id: `${memberName}:spawn_accepted`, - memberName, - severity: 'info', - code: 'spawn_accepted', - label: `${memberName} - spawn accepted`, - detail: entry.runtimeDiagnostic, - observedAt, - }); - } - } - return items.length > 0 ? items : undefined; -} - -function buildWorkspaceTrustPreflightLaunchDiagnostic( - execution: WorkspaceTrustExecutionResult -): TeamLaunchDiagnosticItem | null { - if (execution.status === 'cancelled') { - return null; - } - - const severity = - execution.status === 'blocked' - ? 'error' - : execution.status === 'soft_failed' - ? 'warning' - : 'info'; - const label = - execution.status === 'blocked' - ? 'Workspace trust preflight blocked launch' - : execution.status === 'soft_failed' - ? 'Workspace trust preflight could not verify trust' - : 'Workspace trust preflight completed'; - const detail = - execution.errorMessage?.trim() || - execution.errorCode?.trim() || - execution.evidence?.find((item) => item.trim().length > 0)?.trim(); - - return { - id: 'workspace-trust:preflight', - severity, - code: 'workspace_trust_preflight', - label, - ...(detail ? { detail } : {}), - observedAt: nowIso(), - }; -} - -function mergeLaunchDiagnosticItem( - items: readonly TeamLaunchDiagnosticItem[] | undefined, - item: TeamLaunchDiagnosticItem -): TeamLaunchDiagnosticItem[] { - return [...(items ?? []).filter((candidate) => candidate.id !== item.id), item]; -} - -function buildCombinedLogs( - stdoutBuffer: string | undefined, - stderrBuffer: string | undefined -): string { - const stdoutTrimmed = (stdoutBuffer ?? '').trim(); - const stderrTrimmed = (stderrBuffer ?? '').trim(); - - if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) { - return ''; - } - if (stdoutTrimmed.length > 0 && stderrTrimmed.length === 0) { - return stdoutTrimmed; - } - if (stdoutTrimmed.length === 0 && stderrTrimmed.length > 0) { - return stderrTrimmed; - } - return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n'); -} - interface AgentTeamsMcpConfigEntry { command?: unknown; args?: unknown; @@ -4674,231 +2966,6 @@ function emitLogsProgress(run: ProvisioningRun): void { run.onProgress(run.progress); } -type CliLogStream = 'stdout' | 'stderr' | 'unknown'; - -interface CliLogLine { - stream: CliLogStream; - text: string; -} - -interface CliExitFailurePresentation { - message?: string; - error: string; -} - -const USER_FACING_CLI_NOISE_TEXT_PATTERN = - /additionalContext|skill_flow|EXTREMELY_IMPORTANT|superpowers:using-superpowers|TodoWrite|Skill tool|Invoke Skill tool|Might any skill apply|relevant or requested skills BEFORE|hook_response|hook_started|hook_progress/i; - -const USER_FACING_STDOUT_ERROR_PATTERN = - /\b(error|failed|failure|fatal|exception|traceback|uncaught|unauthorized|forbidden|quota|rate limit|not authenticated|invalid api key|token refresh failed|warning)\b|please run \/login/i; - -function parseCliLogLinesFromText(text: string): CliLogLine[] { - const lines: CliLogLine[] = []; - let currentStream: CliLogStream = 'unknown'; - for (const rawLine of text.split(/\r?\n/)) { - const trimmed = rawLine.trim(); - if (!trimmed) { - continue; - } - if (trimmed === '[stdout]') { - currentStream = 'stdout'; - continue; - } - if (trimmed === '[stderr]') { - currentStream = 'stderr'; - continue; - } - lines.push({ stream: currentStream, text: trimmed }); - } - return lines; -} - -function getCliLogLinesForUserFacingError(run: ProvisioningRun): CliLogLine[] { - const lineHistory = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : []; - const lines = lineHistory.length > 0 ? parseCliLogLinesFromText(lineHistory.join('\n')) : []; - const combinedBufferLines = parseCliLogLinesFromText( - buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer) - ); - - if (lines.length === 0) { - return combinedBufferLines; - } - - // `claudeLogLines` stores complete newline-delimited lines. Add raw ring-buffer - // lines as a fallback only when they contain user-facing material that may be - // sitting in a final partial stderr/stdout line at process close. - const seen = new Set(lines.map((line) => `${line.stream}:${line.text}`)); - for (const line of combinedBufferLines) { - const key = `${line.stream}:${line.text}`; - if (!seen.has(key) && isPotentiallyUserFacingCliLine(line)) { - lines.push(line); - seen.add(key); - } - } - return lines; -} - -function isNoiseCliLine(text: string): boolean { - return USER_FACING_CLI_NOISE_TEXT_PATTERN.test(text); -} - -function isPotentiallyUserFacingCliLine(line: CliLogLine): boolean { - if (isNoiseCliLine(line.text)) { - return false; - } - if (line.stream === 'stderr') { - return true; - } - return USER_FACING_STDOUT_ERROR_PATTERN.test(line.text); -} - -function extractStringField(value: unknown, key: string): string | undefined { - if (!value || typeof value !== 'object') { - return undefined; - } - const raw = (value as Record)[key]; - return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined; -} - -function extractStructuredCliError(parsed: Record): string | undefined { - const type = typeof parsed.type === 'string' ? parsed.type : undefined; - const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : undefined; - - if (type === 'system') { - if (subtype === 'team_bootstrap' && parsed.event === 'failed') { - return extractStringField(parsed, 'reason'); - } - if (subtype === 'init' || subtype?.startsWith('hook_')) { - return undefined; - } - return undefined; - } - - if (type === 'result') { - const result = parsed.result; - const resultSubtype = subtype ?? extractStringField(result, 'subtype'); - if (resultSubtype === 'success' || parsed.outcome === 'success') { - return undefined; - } - if (resultSubtype === 'error' || resultSubtype?.startsWith('error_')) { - return ( - extractStringField(parsed, 'error') ?? - extractStringField(result, 'error') ?? - extractStringField(parsed, 'result') - ); - } - return undefined; - } - - if (type === 'error') { - return extractStringField(parsed, 'error') ?? extractStringField(parsed, 'message'); - } - - return undefined; -} - -function buildSanitizedCliExitError(run: ProvisioningRun): string | undefined { - const errorLines: string[] = []; - for (const line of getCliLogLinesForUserFacingError(run)) { - if (!line.text || isNoiseCliLine(line.text)) { - continue; - } - try { - const parsed = JSON.parse(line.text) as Record; - const structuredError = extractStructuredCliError(parsed); - if (structuredError && !isNoiseCliLine(structuredError)) { - errorLines.push(structuredError); - } - continue; - } catch { - // Non-JSON stderr/plain CLI errors are handled below. - } - - if (isPotentiallyUserFacingCliLine(line)) { - errorLines.push(line.text); - } - } - - const deduped = [...new Set(errorLines.map((line) => line.trim()).filter(Boolean))]; - if (deduped.length === 0) { - return undefined; - } - return deduped.join('\n').slice(-4000); -} - -function formatPendingBootstrapMemberNames(run: ProvisioningRun): string { - const pending = run.expectedMembers.filter((name) => { - const status = run.memberSpawnStatuses.get(name); - return status?.bootstrapConfirmed !== true; - }); - const names = pending.length > 0 ? pending : run.expectedMembers; - if (names.length === 0) { - return 'unknown'; - } - const visible = names.slice(0, 6); - const suffix = names.length > visible.length ? ` and ${names.length - visible.length} more` : ''; - return `${visible.join(', ')}${suffix}`; -} - -function buildDeterministicBootstrapExitFailure(run: ProvisioningRun): CliExitFailurePresentation { - if (!run.lastDeterministicBootstrapEvent) { - return { - message: 'Launch bootstrap was not confirmed', - error: - 'Codex runtime exited before deterministic team bootstrap started. No team_bootstrap event was received.', - }; - } - - if (!run.deterministicBootstrapMemberSpawnSeen) { - const lastStage = run.lastDeterministicBootstrapPhase - ? `${run.lastDeterministicBootstrapEvent}/${run.lastDeterministicBootstrapPhase}` - : run.lastDeterministicBootstrapEvent; - return { - message: 'Launch bootstrap was not confirmed', - error: `Codex runtime exited during deterministic team bootstrap before teammate spawning started. Last bootstrap event: ${lastStage}.`, - }; - } - - return { - message: 'Launch bootstrap was not confirmed', - error: `Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: ${formatPendingBootstrapMemberNames(run)}.`, - }; -} - -function buildCliExitFailurePresentation( - run: ProvisioningRun, - code: number | null -): CliExitFailurePresentation { - const trimmed = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer).trim(); - const cliCommandLabel = getConfiguredCliCommandLabel(); - if (trimmed.length > 0) { - if (trimmed.toLowerCase().includes('please run /login')) { - return { - error: - `${cliCommandLabel} reports it is not authenticated ("Please run /login"). ` + - 'Run the CLI in a normal terminal and complete login, then retry. ' + - 'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.', - }; - } - const sanitized = buildSanitizedCliExitError(run); - if (sanitized) { - return { error: sanitized }; - } - } - - if (run.deterministicBootstrap) { - return buildDeterministicBootstrapExitFailure(run); - } - - if (code === 1) { - return { - error: `${cliCommandLabel} exited with code 1 without user-facing stdout/stderr. Typical causes: missing auth/onboarding, interactive TTY requirements, or an early bootstrap/runtime crash. Check \`~/.claude/debug/latest\` for the real stack and retry.`, - }; - } - - return { error: `${cliCommandLabel} exited with code ${code ?? 'unknown'}` }; -} - interface CachedProbeResult { cacheKey: string; claudePath: string; @@ -21311,7 +19378,7 @@ export class TeamProvisioningService { promptSize, expectedMembersCount: effectiveMemberSpecs.length, }); - logRuntimeLaunchSnapshot(request.teamName, claudePath, spawnArgs, request, shellEnv, { + logRuntimeLaunchSnapshot(logger, request.teamName, claudePath, spawnArgs, request, shellEnv, { geminiRuntimeAuth, promptSize, expectedMembersCount: effectiveMemberSpecs.length, @@ -22628,12 +20695,20 @@ export class TeamProvisioningService { promptSize, expectedMembersCount: effectiveMemberSpecs.length, }); - logRuntimeLaunchSnapshot(request.teamName, claudePath, finalLaunchArgs, request, shellEnv, { - geminiRuntimeAuth, - promptSize, - expectedMembersCount: effectiveMemberSpecs.length, - launchIdentity, - }); + logRuntimeLaunchSnapshot( + logger, + request.teamName, + claudePath, + finalLaunchArgs, + request, + shellEnv, + { + geminiRuntimeAuth, + promptSize, + expectedMembersCount: effectiveMemberSpecs.length, + launchIdentity, + } + ); // Deterministic bootstrap launches fresh because --team-bootstrap-spec and // --resume are not a supported orchestrator combination. emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); @@ -35423,7 +33498,9 @@ export class TeamProvisioningService { return; } - const failurePresentation = buildCliExitFailurePresentation(run, code); + const failurePresentation = buildCliExitFailurePresentation(run, code, { + cliCommandLabel: getConfiguredCliCommandLabel(), + }); const runtimeFailureLabel = getRunRuntimeFailureLabel(run); const progress = updateProgress( run, diff --git a/src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts b/src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts new file mode 100644 index 00000000..4a6c9ffa --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts @@ -0,0 +1,257 @@ +type CliLogStream = 'stdout' | 'stderr' | 'unknown'; + +interface CliLogLine { + stream: CliLogStream; + text: string; +} + +export interface CliExitFailurePresentation { + message?: string; + error: string; +} + +export interface CliExitPresentationRun { + stdoutBuffer: string; + stderrBuffer: string; + claudeLogLines?: string[]; + deterministicBootstrap: boolean; + lastDeterministicBootstrapEvent?: string; + lastDeterministicBootstrapPhase?: string; + deterministicBootstrapMemberSpawnSeen: boolean; + expectedMembers: string[]; + memberSpawnStatuses: ReadonlyMap; +} + +const USER_FACING_CLI_NOISE_TEXT_PATTERN = + /additionalContext|skill_flow|EXTREMELY_IMPORTANT|superpowers:using-superpowers|TodoWrite|Skill tool|Invoke Skill tool|Might any skill apply|relevant or requested skills BEFORE|hook_response|hook_started|hook_progress/i; + +const USER_FACING_STDOUT_ERROR_PATTERN = + /\b(error|failed|failure|fatal|exception|traceback|uncaught|unauthorized|forbidden|quota|rate limit|not authenticated|invalid api key|token refresh failed|warning)\b|please run \/login/i; + +export function buildCombinedLogs( + stdoutBuffer: string | undefined, + stderrBuffer: string | undefined +): string { + const stdoutTrimmed = (stdoutBuffer ?? '').trim(); + const stderrTrimmed = (stderrBuffer ?? '').trim(); + + if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) { + return ''; + } + if (stdoutTrimmed.length > 0 && stderrTrimmed.length === 0) { + return stdoutTrimmed; + } + if (stdoutTrimmed.length === 0 && stderrTrimmed.length > 0) { + return stderrTrimmed; + } + return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n'); +} + +export function parseCliLogLinesFromText(text: string): CliLogLine[] { + const lines: CliLogLine[] = []; + let currentStream: CliLogStream = 'unknown'; + for (const rawLine of text.split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + if (trimmed === '[stdout]') { + currentStream = 'stdout'; + continue; + } + if (trimmed === '[stderr]') { + currentStream = 'stderr'; + continue; + } + lines.push({ stream: currentStream, text: trimmed }); + } + return lines; +} + +function getCliLogLinesForUserFacingError(run: CliExitPresentationRun): CliLogLine[] { + const lineHistory = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : []; + const lines = lineHistory.length > 0 ? parseCliLogLinesFromText(lineHistory.join('\n')) : []; + const combinedBufferLines = parseCliLogLinesFromText( + buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer) + ); + + if (lines.length === 0) { + return combinedBufferLines; + } + + // claudeLogLines stores complete newline-delimited lines. Add raw ring-buffer + // lines as a fallback only when they contain user-facing material that may be + // sitting in a final partial stderr/stdout line at process close. + const seen = new Set(lines.map((line) => `${line.stream}:${line.text}`)); + for (const line of combinedBufferLines) { + const key = `${line.stream}:${line.text}`; + if (!seen.has(key) && isPotentiallyUserFacingCliLine(line)) { + lines.push(line); + seen.add(key); + } + } + return lines; +} + +function isNoiseCliLine(text: string): boolean { + return USER_FACING_CLI_NOISE_TEXT_PATTERN.test(text); +} + +function isPotentiallyUserFacingCliLine(line: CliLogLine): boolean { + if (isNoiseCliLine(line.text)) { + return false; + } + if (line.stream === 'stderr') { + return true; + } + return USER_FACING_STDOUT_ERROR_PATTERN.test(line.text); +} + +function extractStringField(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const raw = (value as Record)[key]; + return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined; +} + +function extractStructuredCliError(parsed: Record): string | undefined { + const type = typeof parsed.type === 'string' ? parsed.type : undefined; + const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : undefined; + + if (type === 'system') { + if (subtype === 'team_bootstrap' && parsed.event === 'failed') { + return extractStringField(parsed, 'reason'); + } + if (subtype === 'init' || subtype?.startsWith('hook_')) { + return undefined; + } + return undefined; + } + + if (type === 'result') { + const result = parsed.result; + const resultSubtype = subtype ?? extractStringField(result, 'subtype'); + if (resultSubtype === 'success' || parsed.outcome === 'success') { + return undefined; + } + if (resultSubtype === 'error' || resultSubtype?.startsWith('error_')) { + return ( + extractStringField(parsed, 'error') ?? + extractStringField(result, 'error') ?? + extractStringField(parsed, 'result') + ); + } + return undefined; + } + + if (type === 'error') { + return extractStringField(parsed, 'error') ?? extractStringField(parsed, 'message'); + } + + return undefined; +} + +export function buildSanitizedCliExitError(run: CliExitPresentationRun): string | undefined { + const errorLines: string[] = []; + for (const line of getCliLogLinesForUserFacingError(run)) { + if (!line.text || isNoiseCliLine(line.text)) { + continue; + } + try { + const parsed = JSON.parse(line.text) as Record; + const structuredError = extractStructuredCliError(parsed); + if (structuredError && !isNoiseCliLine(structuredError)) { + errorLines.push(structuredError); + } + continue; + } catch { + // Non-JSON stderr/plain CLI errors are handled below. + } + + if (isPotentiallyUserFacingCliLine(line)) { + errorLines.push(line.text); + } + } + + const deduped = [...new Set(errorLines.map((line) => line.trim()).filter(Boolean))]; + if (deduped.length === 0) { + return undefined; + } + return deduped.join('\n').slice(-4000); +} + +export function formatPendingBootstrapMemberNames(run: CliExitPresentationRun): string { + const pending = run.expectedMembers.filter((name) => { + const status = run.memberSpawnStatuses.get(name); + return status?.bootstrapConfirmed !== true; + }); + const names = pending.length > 0 ? pending : run.expectedMembers; + if (names.length === 0) { + return 'unknown'; + } + const visible = names.slice(0, 6); + const suffix = names.length > visible.length ? ` and ${names.length - visible.length} more` : ''; + return `${visible.join(', ')}${suffix}`; +} + +export function buildDeterministicBootstrapExitFailure( + run: CliExitPresentationRun +): CliExitFailurePresentation { + if (!run.lastDeterministicBootstrapEvent) { + return { + message: 'Launch bootstrap was not confirmed', + error: + 'Codex runtime exited before deterministic team bootstrap started. No team_bootstrap event was received.', + }; + } + + if (!run.deterministicBootstrapMemberSpawnSeen) { + const lastStage = run.lastDeterministicBootstrapPhase + ? `${run.lastDeterministicBootstrapEvent}/${run.lastDeterministicBootstrapPhase}` + : run.lastDeterministicBootstrapEvent; + return { + message: 'Launch bootstrap was not confirmed', + error: `Codex runtime exited during deterministic team bootstrap before teammate spawning started. Last bootstrap event: ${lastStage}.`, + }; + } + + return { + message: 'Launch bootstrap was not confirmed', + error: `Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: ${formatPendingBootstrapMemberNames(run)}.`, + }; +} + +export function buildCliExitFailurePresentation( + run: CliExitPresentationRun, + code: number | null, + options: { cliCommandLabel: string } +): CliExitFailurePresentation { + const trimmed = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer).trim(); + if (trimmed.length > 0) { + if (trimmed.toLowerCase().includes('please run /login')) { + return { + error: + `${options.cliCommandLabel} reports it is not authenticated ("Please run /login"). ` + + 'Run the CLI in a normal terminal and complete login, then retry. ' + + 'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.', + }; + } + const sanitized = buildSanitizedCliExitError(run); + if (sanitized) { + return { error: sanitized }; + } + } + + if (run.deterministicBootstrap) { + return buildDeterministicBootstrapExitFailure(run); + } + + if (code === 1) { + return { + error: `${options.cliCommandLabel} exited with code 1 without user-facing stdout/stderr. Typical causes: missing auth/onboarding, interactive TTY requirements, or an early bootstrap/runtime crash. Check \`~/.claude/debug/latest\` for the real stack and retry.`, + }; + } + + return { error: `${options.cliCommandLabel} exited with code ${code ?? 'unknown'}` }; +} diff --git a/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts new file mode 100644 index 00000000..51ab1e90 --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts @@ -0,0 +1,188 @@ +import * as path from 'path'; + +import { + ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS, + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER, + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, +} from '../../runtime/anthropicTeamApiKeyHelper'; + +import type { TeamProviderId } from '@shared/types'; + +const DIRECT_TMUX_RESTART_ENV_KEYS = [ + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_TEAM_CONTROL_URL', + 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_ENTRY_PROVIDER', + 'CLAUDE_CODE_GEMINI_BACKEND', + 'CLAUDE_CODE_CODEX_BACKEND', + 'CODEX_HOME', + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AWS_WORKSPACE_ID', + 'ANTHROPIC_AWS_API_KEY', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'GEMINI_BASE_URL', + 'GEMINI_API_VERSION', + 'GEMINI_API_KEY', + 'CODEX_API_KEY', + 'OPENAI_API_KEY', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_PROJECT_ID', + 'GCLOUD_PROJECT', + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'NO_PROXY', + 'no_proxy', + 'SSL_CERT_FILE', + 'NODE_EXTRA_CA_CERTS', + 'REQUESTS_CA_BUNDLE', + 'CURL_CA_BUNDLE', +] as const; + +const DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS = [ + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_ENTRY_PROVIDER', +] as const; + +const INTERACTIVE_SHELL_COMMANDS = new Set([ + 'bash', + 'zsh', + 'sh', + 'fish', + 'nu', + 'pwsh', + 'powershell', + 'cmd', + 'cmd.exe', +]); + +export function shellQuote(value: string): string { + if (value.length === 0) { + return "''"; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function isInteractiveShellCommand(command: string | undefined): boolean { + const normalized = command?.trim().toLowerCase(); + if (!normalized) { + return false; + } + return INTERACTIVE_SHELL_COMMANDS.has(path.basename(normalized)); +} + +function getDirectRestartEntryProvider(providerId: TeamProviderId): string { + return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; +} + +export function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + + try { + const url = new URL(trimmed); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + !url.username && + !url.password && + url.hostname !== 'api.anthropic.com' && + url.hostname !== 'api-staging.anthropic.com' + ); + } catch { + return false; + } +} + +export function hasAnthropicCompatibleAuthTokenEnv(env: NodeJS.ProcessEnv): boolean { + return Boolean( + isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL) && env.ANTHROPIC_AUTH_TOKEN?.trim() + ); +} + +export function buildDirectTmuxRestartEnvAssignments( + env: NodeJS.ProcessEnv, + providerId: TeamProviderId +): string { + const assignments = new Map(); + assignments.set('CLAUDECODE', '1'); + assignments.set('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1'); + + for (const key of DIRECT_TMUX_RESTART_ENV_KEYS) { + const value = env[key]; + if (typeof value === 'string' && value.length > 0) { + assignments.set(key, value); + } + } + + for (const key of DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS) { + assignments.set(key, ''); + } + assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1'); + assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId)); + if (providerId === 'anthropic') { + if (hasAnthropicCompatibleAuthTokenEnv(env)) { + assignments.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL?.trim() ?? ''); + assignments.set('ANTHROPIC_AUTH_TOKEN', env.ANTHROPIC_AUTH_TOKEN?.trim() ?? ''); + if (!env.ANTHROPIC_API_KEY?.trim()) { + assignments.set('ANTHROPIC_API_KEY', ''); + } + } else if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) { + assignments.set('ANTHROPIC_AUTH_TOKEN', ''); + } + } + if ( + providerId === 'anthropic' && + env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER + ) { + assignments.set( + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER + ); + const settingsPath = env[CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV]; + if (typeof settingsPath === 'string') { + assignments.set(CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, settingsPath); + } + for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { + assignments.set(key, ''); + } + } + + return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' '); +} + +export function buildDirectTmuxRestartCommand(input: { + cwd: string; + env: NodeJS.ProcessEnv; + providerId: TeamProviderId; + binaryPath: string; + args: string[]; +}): string { + const envAssignments = buildDirectTmuxRestartEnvAssignments(input.env, input.providerId); + const command = [ + 'cd', + shellQuote(input.cwd), + '&&', + 'env', + envAssignments, + shellQuote(input.binaryPath), + ...input.args.map(shellQuote), + ].join(' '); + return `(${command}); __claude_teammate_exit=$?; printf '\\n__CLAUDE_TEAMMATE_EXIT__:%s\\n' "$__claude_teammate_exit"`; +} diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts new file mode 100644 index 00000000..19217836 --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts @@ -0,0 +1,123 @@ +import { fromProvisioningMembers } from '@features/team-runtime-lanes'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + +import type { TeamCreateRequest } from '@shared/types'; + +export type TeamLaunchCompatibilityLevel = 'ready' | 'repairable' | 'unsafe'; +export type TeamLaunchCompatibilityRosterSource = 'members-meta' | 'config' | 'inboxes' | 'missing'; +export type TeamLaunchCompatibilityRepairAction = 'materialize-members-meta'; + +export interface TeamLaunchCompatibilityReport { + level: TeamLaunchCompatibilityLevel; + rosterSource: TeamLaunchCompatibilityRosterSource; + members: TeamCreateRequest['members']; + warnings: string[]; + blockers: string[]; + repairAction?: TeamLaunchCompatibilityRepairAction; +} + +export function isOpenCodeLegacyProvisioningRequest(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): boolean { + return ( + normalizeOptionalTeamProviderId(request.providerId) === 'opencode' || + (request.members ?? []).some( + (member) => + normalizeOptionalTeamProviderId(member.providerId) === 'opencode' || + normalizeOptionalTeamProviderId(member.provider) === 'opencode' + ) + ); +} + +export function isPureOpenCodeProvisioningRequest(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): boolean { + if (!isOpenCodeLegacyProvisioningRequest(request)) { + return false; + } + + const rootProviderId = normalizeOptionalTeamProviderId(request.providerId); + if (rootProviderId && rootProviderId !== 'opencode') { + return false; + } + + return (request.members ?? []).every((member) => { + const memberProviderId = + normalizeOptionalTeamProviderId(member.providerId) ?? + normalizeOptionalTeamProviderId(member.provider); + return !memberProviderId || memberProviderId === 'opencode'; + }); +} + +export function getOpenCodeMixedProviderProvisioningError(): string { + return ( + 'This OpenCode mixed-team request is outside the current support scope. ' + + 'Supported mixed teams keep the lead on Anthropic or Codex. OpenCode-led mixed teams still remain blocked in this phase.' + ); +} + +export function getMixedLaunchFallbackRecoveryError(): string { + return 'This old mixed team is missing stable member metadata. Open Edit Team and save the roster once before launching.'; +} + +export function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: { + providerId?: unknown; + members?: readonly { providerId?: unknown; provider?: unknown }[]; +}): void { + if (!isOpenCodeLegacyProvisioningRequest(request)) { + return; + } + const lanePlan = fromProvisioningMembers( + normalizeOptionalTeamProviderId(request.providerId), + (request.members ?? []).map((member, index) => ({ + name: `member-${index + 1}`, + providerId: + normalizeOptionalTeamProviderId(member.providerId) ?? + normalizeOptionalTeamProviderId(member.provider), + })) + ); + if (!lanePlan.ok) { + throw new Error(lanePlan.message || getOpenCodeMixedProviderProvisioningError()); + } + if (!isPureOpenCodeProvisioningRequest(request)) { + return; + } + throw new Error( + 'OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path. ' + + 'Use the gated OpenCode runtime adapter once production launch is enabled.' + ); +} + +export function mergeProvisioningWarnings( + existing: string[] | undefined, + nextWarning: string | null +): string[] | undefined { + if (!nextWarning) return existing; + const merged = (existing ?? []).filter((warning) => warning !== nextWarning); + merged.push(nextWarning); + return merged.length > 0 ? merged : undefined; +} + +const DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD = 8; +const DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS = 20; + +export function buildLargeDeterministicBootstrapWarning(memberCount: number): string | null { + if (memberCount <= DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD) { + return null; + } + return ( + `Large Codex team launch: ${memberCount} primary teammates will bootstrap in one runtime. ` + + `Launches above ${DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD} teammates can be slower and more likely to hit provider rate limits or bootstrap timeouts.` + ); +} + +export function assertDeterministicBootstrapPrimaryMemberLimit(memberCount: number): void { + if (memberCount <= DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS) { + return; + } + throw new Error( + `Codex deterministic bootstrap currently supports up to ${DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS} primary teammates; this team has ${memberCount}. Reduce primary teammates or move extra OpenCode members to secondary lanes.` + ); +} diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts new file mode 100644 index 00000000..4eb1290f --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts @@ -0,0 +1,197 @@ +import type { WorkspaceTrustExecutionResult } from '@features/workspace-trust/main'; +import type { MemberSpawnStatusEntry, TeamLaunchDiagnosticItem } from '@shared/types'; + +export interface TeamProvisioningLaunchDiagnosticsRun { + isLaunch: boolean; + memberSpawnStatuses?: ReadonlyMap | null; +} + +interface LaunchDiagnosticsClockOptions { + nowIso?: () => string; +} + +const defaultNowIso = (): string => new Date().toISOString(); + +export function mentionsProcessTableUnavailable(value: string | undefined): boolean { + return /\bprocess table\b.*\bunavailable\b/i.test(value ?? ''); +} + +export function buildLaunchDiagnosticsFromRun( + run: TeamProvisioningLaunchDiagnosticsRun, + options: LaunchDiagnosticsClockOptions = {} +): TeamLaunchDiagnosticItem[] | undefined { + const memberSpawnStatuses = run.memberSpawnStatuses; + if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) { + return undefined; + } + + const observedAt = (options.nowIso ?? defaultNowIso)(); + const items: TeamLaunchDiagnosticItem[] = []; + for (const [memberName, entry] of memberSpawnStatuses.entries()) { + if (entry.launchState === 'confirmed_alive') { + items.push({ + id: `${memberName}:bootstrap_confirmed`, + memberName, + severity: 'info', + code: 'bootstrap_confirmed', + label: `${memberName} - bootstrap confirmed`, + observedAt, + }); + continue; + } + if (entry.launchState === 'failed_to_start') { + items.push({ + id: `${memberName}:bootstrap_stalled`, + memberName, + severity: 'error', + code: 'bootstrap_stalled', + label: `${memberName} - failed to start`, + detail: entry.hardFailureReason ?? entry.error, + observedAt, + }); + continue; + } + if (entry.launchState === 'runtime_pending_permission') { + items.push({ + id: `${memberName}:permission_pending`, + memberName, + severity: 'warning', + code: 'permission_pending', + label: `${memberName} - awaiting permission`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.bootstrapStalled === true) { + items.push({ + id: `${memberName}:bootstrap_stalled`, + memberName, + severity: 'warning', + code: 'bootstrap_stalled', + label: `${memberName} - bootstrap stalled`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) { + items.push({ + id: `${memberName}:process_table_unavailable`, + memberName, + severity: 'warning', + code: 'process_table_unavailable', + label: `${memberName} - process table unavailable`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.livenessKind === 'shell_only') { + items.push({ + id: `${memberName}:tmux_shell_only`, + memberName, + severity: 'warning', + code: 'tmux_shell_only', + label: `${memberName} - shell only`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.livenessKind === 'runtime_process_candidate') { + items.push({ + id: `${memberName}:runtime_process_candidate`, + memberName, + severity: 'warning', + code: 'runtime_process_candidate', + label: `${memberName} - bootstrap unconfirmed`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.livenessKind === 'runtime_process') { + items.push({ + id: `${memberName}:runtime_process_detected`, + memberName, + severity: 'info', + code: 'runtime_process_detected', + label: `${memberName} - waiting for bootstrap`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if ( + entry.livenessKind === 'registered_only' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'not_found' + ) { + items.push({ + id: `${memberName}:runtime_not_found`, + memberName, + severity: 'warning', + code: 'runtime_not_found', + label: `${memberName} - waiting for runtime`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.agentToolAccepted) { + items.push({ + id: `${memberName}:spawn_accepted`, + memberName, + severity: 'info', + code: 'spawn_accepted', + label: `${memberName} - spawn accepted`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + } + } + return items.length > 0 ? items : undefined; +} + +export function buildWorkspaceTrustPreflightLaunchDiagnostic( + execution: WorkspaceTrustExecutionResult, + options: LaunchDiagnosticsClockOptions = {} +): TeamLaunchDiagnosticItem | null { + if (execution.status === 'cancelled') { + return null; + } + + const severity = + execution.status === 'blocked' + ? 'error' + : execution.status === 'soft_failed' + ? 'warning' + : 'info'; + const label = + execution.status === 'blocked' + ? 'Workspace trust preflight blocked launch' + : execution.status === 'soft_failed' + ? 'Workspace trust preflight could not verify trust' + : 'Workspace trust preflight completed'; + const detail = + execution.errorMessage?.trim() || + execution.errorCode?.trim() || + execution.evidence?.find((item) => item.trim().length > 0)?.trim(); + + return { + id: 'workspace-trust:preflight', + severity, + code: 'workspace_trust_preflight', + label, + ...(detail ? { detail } : {}), + observedAt: (options.nowIso ?? defaultNowIso)(), + }; +} + +export function mergeLaunchDiagnosticItem( + items: readonly TeamLaunchDiagnosticItem[] | undefined, + item: TeamLaunchDiagnosticItem +): TeamLaunchDiagnosticItem[] { + return [...(items ?? []).filter((candidate) => candidate.id !== item.id), item]; +} diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts new file mode 100644 index 00000000..58165db5 --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts @@ -0,0 +1,131 @@ +import { mentionsProcessTableUnavailable } from './TeamProvisioningLaunchDiagnostics'; +import { isBootstrapInstructionPrompt } from './TeamProvisioningPromptBuilders'; + +import type { MemberLaunchState } from '@shared/types'; + +export function isNeverSpawnedDuringLaunchReason(reason?: string): boolean { + return reason?.trim() === 'Teammate was never spawned during launch.'; +} + +export function isLaunchGraceWindowFailureReason(reason?: string): boolean { + return reason?.trim() === 'Teammate did not join within the launch grace window.'; +} + +export function isConfigRegistrationFailureReason(reason?: string): boolean { + return ( + reason?.trim() === + 'Teammate was not registered in config.json during launch. Persistent spawn failed.' + ); +} + +export function isOpenCodeBridgeLaunchFailureReason(reason?: string): boolean { + return reason?.trim() === 'OpenCode bridge reported member launch failure'; +} + +export function isRegisteredRuntimeMetadataFailureReason(reason?: string): boolean { + return reason?.trim() === 'registered runtime metadata without live process'; +} + +export function isProcessTableUnavailableFailureReason(reason?: string): boolean { + const text = reason?.trim(); + if (!text || !mentionsProcessTableUnavailable(text)) { + return false; + } + return ( + /^process table (?:is )?unavailable$/i.test(text) || + /^runtime pid could not be verified because process table (?:is )?unavailable$/i.test(text) + ); +} + +export function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null { + const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim()); + const baseReason = match?.[1]?.trim(); + return baseReason && baseReason.length > 0 ? baseReason : null; +} + +function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean { + return ( + isNeverSpawnedDuringLaunchReason(reason) || + isLaunchGraceWindowFailureReason(reason) || + isConfigRegistrationFailureReason(reason) || + isRegisteredRuntimeMetadataFailureReason(reason) || + isOpenCodeBridgeLaunchFailureReason(reason) || + isBootstrapMcpResourceReadFailureReason(reason) || + isBootstrapCheckInTimeoutFailureReason(reason) || + isBootstrapInstructionPromptFailureReason(reason) || + isLaunchCleanupBootstrapIncompleteFailureReason(reason) + ); +} + +export function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean { + const text = reason?.trim().toLowerCase() ?? ''; + return ( + text.includes('resources/read failed') && + text.includes('member_briefing') && + (text.includes('method not found') || text.includes('mcp error')) + ); +} + +export function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean { + return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.'; +} + +export function isBootstrapInstructionPromptFailureReason(reason?: string): boolean { + return typeof reason === 'string' && isBootstrapInstructionPrompt(reason); +} + +export function isLaunchCleanupBootstrapIncompleteFailureReason(reason?: string): boolean { + const text = reason?.trim(); + if (!text) { + return false; + } + if ( + text === 'Launch ended before teammate bootstrap completed.' || + text === 'Deterministic bootstrap failed before teammate check-in.' + ) { + return true; + } + return ( + text.startsWith('Launch ended before teammate bootstrap completed. ') && + text.includes('Runtime process was alive after bootstrap failure') + ); +} + +export function isAutoClearableLaunchFailureReason(reason?: string): boolean { + const text = reason?.trim(); + if (!text) { + return false; + } + const baseReason = stripProcessTableUnavailableDiagnosticSuffix(text); + return ( + isBaseAutoClearableLaunchFailureReason(text) || + isProcessTableUnavailableFailureReason(text) || + (baseReason != null && isBaseAutoClearableLaunchFailureReason(baseReason)) + ); +} + +export function deriveMemberLaunchState(entry: { + agentToolAccepted?: boolean; + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + hardFailure?: boolean; + skippedForLaunch?: boolean; + pendingPermissionRequestIds?: string[]; +}): MemberLaunchState { + if (entry.skippedForLaunch) { + return 'skipped_for_launch'; + } + if (entry.hardFailure) { + return 'failed_to_start'; + } + if (entry.bootstrapConfirmed) { + return 'confirmed_alive'; + } + if ((entry.pendingPermissionRequestIds?.length ?? 0) > 0) { + return 'runtime_pending_permission'; + } + if (entry.runtimeAlive || entry.agentToolAccepted) { + return 'runtime_pending_bootstrap'; + } + return 'starting'; +} diff --git a/src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts b/src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts new file mode 100644 index 00000000..e793440c --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts @@ -0,0 +1,44 @@ +import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; + +export function matchesMemberNameOrBase(candidateName: string, memberName: string): boolean { + if (candidateName === memberName) { + return true; + } + const parsed = parseNumericSuffixName(candidateName); + return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName; +} + +export function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean { + return ( + matchesMemberNameOrBase(leftName, rightName) || matchesMemberNameOrBase(rightName, leftName) + ); +} + +export function matchesObservedMemberNameForExpected( + observedName: string, + expectedName: string +): boolean { + return matchesMemberNameOrBase(observedName, expectedName); +} + +export function matchesExactTeamMemberName(candidateName: string, memberName: string): boolean { + const left = candidateName.trim().toLowerCase(); + const right = memberName.trim().toLowerCase(); + return left.length > 0 && left === right; +} + +export function namesMatchCaseInsensitive(left: string, right: string): boolean { + return left.trim().toLowerCase() === right.trim().toLowerCase(); +} + +export function isOpenCodeOverlayMemberRemoved( + metaMembers: readonly { name?: string; removedAt?: unknown }[], + memberName: string +): boolean { + return metaMembers.some( + (member) => + typeof member.name === 'string' && + namesMatchCaseInsensitive(member.name, memberName) && + member.removedAt != null + ); +} diff --git a/src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts b/src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts new file mode 100644 index 00000000..898de6d0 --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts @@ -0,0 +1,192 @@ +import type { MemberSpawnStatusEntry, PersistedTeamLaunchSummary } from '@shared/types'; + +export const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000; +export const MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS = 10_000; +export const MEMBER_LAUNCH_GRACE_MS = 120_000; + +export function shouldWarnOnUnreadableMemberAuditConfig(params: { + nowMs: number; + lastWarnAt: number; + expectedMembers: readonly string[]; + memberSpawnStatuses: ReadonlyMap< + string, + Pick | undefined + >; +}): boolean { + const { nowMs, lastWarnAt, expectedMembers, memberSpawnStatuses } = params; + if (nowMs - lastWarnAt < MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS) { + return false; + } + return expectedMembers.some((memberName) => { + const current = memberSpawnStatuses.get(memberName); + if (!current?.agentToolAccepted || typeof current.firstSpawnAcceptedAt !== 'string') { + return false; + } + const acceptedAtMs = Date.parse(current.firstSpawnAcceptedAt); + return Number.isFinite(acceptedAtMs) && nowMs - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS; + }); +} + +export function shouldWarnOnMissingRegisteredMember(params: { + nowMs: number; + lastWarnAt: number; + graceExpired: boolean; +}): boolean { + const { nowMs, lastWarnAt, graceExpired } = params; + return graceExpired && nowMs - lastWarnAt >= MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +export function parseOptionalIsoMs(value: string | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function deriveTaskActivityPauseAt( + previous: MemberSpawnStatusEntry, + fallbackAt: string +): string { + const fallbackMs = parseOptionalIsoMs(fallbackAt); + const explicitEvidenceMs = Math.max( + parseOptionalIsoMs(previous.lastHeartbeatAt), + parseOptionalIsoMs(previous.livenessLastCheckedAt) + ); + const evidenceMs = + explicitEvidenceMs > 0 ? explicitEvidenceMs : parseOptionalIsoMs(previous.updatedAt); + if (evidenceMs <= 0 || fallbackMs <= 0) { + return fallbackAt; + } + const boundedEvidenceMs = Math.min(evidenceMs, fallbackMs); + const closeMs = Math.max( + boundedEvidenceMs, + Math.min(fallbackMs, boundedEvidenceMs + TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS) + ); + return new Date(closeMs).toISOString(); +} + +export function deriveTaskActivityResumeAt( + previous: MemberSpawnStatusEntry, + evidenceAt: string, + fallbackAt: string +): string { + const fallbackMs = parseOptionalIsoMs(fallbackAt); + const evidenceMs = parseOptionalIsoMs(evidenceAt); + const previousUpdatedMs = parseOptionalIsoMs(previous.updatedAt); + if (evidenceMs <= 0 || fallbackMs <= 0) { + return fallbackAt; + } + if (previousUpdatedMs > 0 && evidenceMs < previousUpdatedMs) { + return fallbackAt; + } + return new Date(Math.min(evidenceMs, fallbackMs)).toISOString(); +} + +export function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { + const updatedAt = nowIso(); + return { + status: 'offline', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + updatedAt, + }; +} + +export function summarizeMemberSpawnStatusRecord( + expectedMembers: readonly string[], + statuses: Record +): PersistedTeamLaunchSummary { + let confirmedCount = 0; + let pendingCount = 0; + let failedCount = 0; + let skippedCount = 0; + let runtimeAlivePendingCount = 0; + let shellOnlyPendingCount = 0; + let runtimeProcessPendingCount = 0; + let runtimeCandidatePendingCount = 0; + let noRuntimePendingCount = 0; + let permissionPendingCount = 0; + const memberNames = Array.from(new Set([...expectedMembers, ...Object.keys(statuses)])); + + for (const memberName of memberNames) { + const entry = statuses[memberName]; + if (!entry) { + pendingCount += 1; + continue; + } + if (entry.launchState === 'confirmed_alive') { + confirmedCount += 1; + continue; + } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedCount += 1; + continue; + } + if (entry.launchState === 'failed_to_start') { + failedCount += 1; + continue; + } + pendingCount += 1; + if (entry.runtimeAlive) { + runtimeAlivePendingCount += 1; + } + if (entry.launchState === 'runtime_pending_permission') { + permissionPendingCount += 1; + } + if (entry.livenessKind === 'shell_only') { + shellOnlyPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process') { + runtimeProcessPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process_candidate') { + runtimeCandidatePendingCount += 1; + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { + noRuntimePendingCount += 1; + } + } + + return { + confirmedCount, + pendingCount, + failedCount, + skippedCount, + runtimeAlivePendingCount, + shellOnlyPendingCount, + runtimeProcessPendingCount, + runtimeCandidatePendingCount, + noRuntimePendingCount, + permissionPendingCount, + }; +} + +export function buildRestartStillRunningReason(memberName: string): string { + return ( + `Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` + + `to be active. The requested settings may not have been applied.` + ); +} + +export function buildRestartDuplicateUnconfirmedReason( + memberName: string, + rawReason?: string +): string { + const suffix = rawReason?.trim() + ? ` Agent returned duplicate_skipped with unrecognized reason "${rawReason.trim()}".` + : ' Agent returned duplicate_skipped without a reason.'; + return ( + `Restart for teammate "${memberName}" could not be confirmed and may not have applied.` + suffix + ); +} + +export function buildRestartGraceTimeoutReason(memberName: string): string { + return `Teammate "${memberName}" did not rejoin within the restart grace window.`; +} diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts new file mode 100644 index 00000000..f6bf6720 --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts @@ -0,0 +1,216 @@ +import { createPersistedLaunchSnapshot } from '../TeamLaunchStateEvaluator'; + +import type { PersistedTeamLaunchMemberState, PersistedTeamLaunchSnapshot } from '@shared/types'; + +export const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC = + 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'; + +const OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON = + 'OpenCode bridge reported member launch failure'; +const OPEN_CODE_SECRET_FLAG_PATTERN = + /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; +const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Z0-9._~+/=-]+/gi; +const OPEN_CODE_SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g; +const OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS = 12_000; + +function nowIso(): string { + return new Date().toISOString(); +} + +export function isPersistedOpenCodeSecondaryLaneMember( + member: PersistedTeamLaunchMemberState | undefined | null +): boolean { + return ( + member?.providerId === 'opencode' && + member.laneKind === 'secondary' && + member.laneOwnerProviderId === 'opencode' && + typeof member.laneId === 'string' && + member.laneId.trim().length > 0 + ); +} + +export function hasStaleOpenCodeSecondaryLaunchDiagnostic( + member: PersistedTeamLaunchMemberState +): boolean { + return hasStaleOpenCodeDiagnostics(getOpenCodeLaunchDiagnosticValues(member)); +} + +export function hasRealOpenCodeLaunchDiagnostic(member: PersistedTeamLaunchMemberState): boolean { + const text = getOpenCodeLaunchDiagnosticValues(member) + .filter((value): value is string => typeof value === 'string') + .join('\n') + .toLowerCase(); + return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text); +} + +export function getOpenCodeLaunchDiagnosticValues( + member: PersistedTeamLaunchMemberState +): readonly unknown[] { + return [member.hardFailureReason, member.runtimeDiagnostic, ...(member.diagnostics ?? [])]; +} + +export function hasStaleOpenCodeDiagnostics(values: readonly unknown[] | undefined): boolean { + const text = (values ?? []) + .filter((value): value is string => typeof value === 'string') + .join('\n') + .toLowerCase(); + if (!text) { + return false; + } + if (hasRealOpenCodeFailureDiagnostic(text)) { + return false; + } + return ( + text.includes('no lane runtime evidence') || + text.includes('no runtime evidence') || + text.includes('runtime evidence was not committed') || + text.includes('no lane runtime evidence was committed') || + text.includes('registered runtime metadata without live process') || + text.includes('member has persisted runtime metadata only') || + text.includes('opencode bridge reported member launch failure') || + text.includes('file lock timeout') || + text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase()) + ); +} + +export function isFileLockTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.toLowerCase().includes('file lock timeout'); +} + +export function hasRealOpenCodeFailureDiagnostic(text: string): boolean { + return ( + /\bauth(?:entication|orization)?\b/.test(text) || + text.includes('api key') || + text.includes('unauthorized') || + text.includes('forbidden') || + text.includes('invalid_request') || + text.includes('model not found') || + text.includes('not found in live opencode catalog') || + text.includes('provider unavailable') || + text.includes('quota') || + text.includes('credits') || + text.includes('max_tokens') || + text.includes('rate limit') || + text.includes('member removed') || + text.includes('session conflict') || + text.includes('run tombstoned') || + text.includes('stop requested') || + text.includes('relaunch started') + ); +} + +export function normalizeOpenCodePersistedFailureReason( + value: string | undefined +): string | undefined { + const trimmed = value?.replace(/\s+/g, ' ').trim(); + if (!trimmed) { + return undefined; + } + return trimmed + .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]') + .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]') + .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]'); +} + +export function redactOpenCodeAppManagedContextText(value: string): string { + return value + .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]') + .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]') + .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]'); +} + +export function boundOpenCodeAppManagedBriefingText(value: string): string { + const normalized = redactOpenCodeAppManagedContextText(value.replace(/\r\n/g, '\n')).trim(); + if (normalized.length <= OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS) { + return normalized; + } + return `${normalized.slice(0, OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS)}\n[truncated app-managed briefing]`; +} + +export function isGenericOpenCodePersistedFailureReason(value: string | undefined): boolean { + const normalized = normalizeOpenCodePersistedFailureReason(value); + return ( + normalized === OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON || + normalized?.startsWith(`${OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON}:`) === true || + normalized?.startsWith('OpenCode secondary lane timing:') === true || + normalized?.startsWith( + 'OpenCode bridge reported ready without all required durable checkpoints:' + ) === true || + normalized?.startsWith( + 'OpenCode bridge reported ready before all expected members were confirmed:' + ) === true || + normalized?.startsWith( + 'OpenCode bootstrap MCP did not complete required tools before assistant response:' + ) === true || + normalized?.startsWith('info:opencode_launch_member_timing:') === true || + normalized?.startsWith('info:opencode_launch_total_timing:') === true + ); +} + +export function selectOpenCodePersistedFailureReasonFromDiagnostics( + member: PersistedTeamLaunchMemberState +): string | undefined { + if (!isPersistedOpenCodeSecondaryLaneMember(member)) { + return undefined; + } + if (member.launchState !== 'failed_to_start' || member.hardFailure !== true) { + return undefined; + } + if (!isGenericOpenCodePersistedFailureReason(member.hardFailureReason)) { + return undefined; + } + for (const value of member.diagnostics ?? []) { + const normalized = normalizeOpenCodePersistedFailureReason(value); + if (!normalized || isGenericOpenCodePersistedFailureReason(normalized)) { + continue; + } + return normalized; + } + return undefined; +} + +export function promoteOpenCodePersistedFailureReasonsFromDiagnostics( + snapshot: PersistedTeamLaunchSnapshot | null +): PersistedTeamLaunchSnapshot | null { + if (!snapshot) { + return null; + } + let changed = false; + const members: Record = { ...snapshot.members }; + for (const [memberName, member] of Object.entries(snapshot.members)) { + const promotedReason = selectOpenCodePersistedFailureReasonFromDiagnostics(member); + if (!promotedReason || promotedReason === member.hardFailureReason) { + continue; + } + members[memberName] = { + ...member, + hardFailureReason: promotedReason, + runtimeDiagnostic: + member.runtimeDiagnostic && + !isGenericOpenCodePersistedFailureReason(member.runtimeDiagnostic) + ? member.runtimeDiagnostic + : promotedReason, + runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'error', + }; + changed = true; + } + if (!changed) { + return snapshot; + } + return createPersistedLaunchSnapshot({ + teamName: snapshot.teamName, + expectedMembers: snapshot.expectedMembers, + bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers, + leadSessionId: snapshot.leadSessionId, + launchPhase: snapshot.launchPhase, + members, + updatedAt: nowIso(), + }); +} + +export function filterStaleOpenCodeOverlayDiagnostics( + values: readonly string[] | undefined +): string[] { + return (values ?? []).filter((value) => !hasStaleOpenCodeDiagnostics([value])); +} diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts new file mode 100644 index 00000000..30ce8949 --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts @@ -0,0 +1,620 @@ +import { + hasRealOpenCodeFailureDiagnostic, + isPersistedOpenCodeSecondaryLaneMember, + normalizeOpenCodePersistedFailureReason, + OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC, +} from './TeamProvisioningOpenCodeDiagnosticsPolicy'; + +import type { + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, +} from '../runtime/TeamRuntimeAdapter'; +import type { + PersistedTeamLaunchMemberState, + TeamAgentRuntimeEntry, + TeamLaunchAggregateState, +} from '@shared/types'; + +export const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; +export const OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC = + 'OpenCode app-managed bootstrap evidence did not commit within 5 min.'; +export const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC = + 'opencode_bootstrap_pending_after_materialized_session'; +export const OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC = + 'OpenCode app-managed bootstrap evidence is pending after materialized session.'; + +const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN = + /\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i; + +export function formatOpenCodeLaneTimingMs(value: number | null | undefined): string { + return typeof value === 'number' && Number.isFinite(value) + ? `${Math.max(0, Math.round(value))}ms` + : 'n/a'; +} + +export function appendDiagnosticOnce( + diagnostics: readonly string[], + diagnostic: string | null +): string[] { + if (!diagnostic || diagnostics.includes(diagnostic)) { + return [...diagnostics]; + } + return [...diagnostics, diagnostic]; +} + +export function buildOpenCodeSecondaryLaneTimingDiagnostic(lane: { + member: { name: string }; + queuedAtMs?: number; + launchStartedAtMs?: number; + launchFinishedAtMs?: number; +}): string | null { + if ( + typeof lane.queuedAtMs !== 'number' || + typeof lane.launchStartedAtMs !== 'number' || + typeof lane.launchFinishedAtMs !== 'number' + ) { + return null; + } + return [ + 'OpenCode secondary lane timing:', + `member=${lane.member.name}`, + `queueWaitMs=${formatOpenCodeLaneTimingMs(lane.launchStartedAtMs - lane.queuedAtMs)}`, + `launchMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.launchStartedAtMs)}`, + `totalMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.queuedAtMs)}`, + ].join(' '); +} + +export function createUnexpectedMixedSecondaryLaneFailureResult(input: { + runId: string; + teamName: string; + memberName: string; + message: string; +}): TeamRuntimeLaunchResult { + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished', + teamLaunchState: 'partial_failure', + members: { + [input.memberName]: { + memberName: input.memberName, + providerId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: input.message, + diagnostics: [input.message], + }, + }, + warnings: [], + diagnostics: [input.message], + }; +} + +export function isExplicitLegacyOpenCodeBootstrap( + value: + | { + bootstrapMode?: 'model_tool_checkin' | 'app_managed_context'; + } + | undefined + | null +): boolean { + return value?.bootstrapMode === 'model_tool_checkin'; +} + +export function hasRecoverableOpenCodeBootstrapDiagnostic(diagnostics: readonly string[]): boolean { + const text = diagnostics.join('\n').toLowerCase(); + if (!text) { + return false; + } + if (hasRealOpenCodeFailureDiagnostic(text)) { + return false; + } + return ( + text.includes('runtime_bootstrap_checkin') || + text.includes('member_briefing') || + text.includes('bootstrap mcp') || + text.includes('member_session_recorded') || + text.includes('not connected') || + text.includes('mcp not connected') || + text.includes('member_launch_reconcile_pending') || + text.includes('member_launch_preview_timeout') + ); +} + +export function collectRuntimeLaunchFailureDiagnostics( + result: TeamRuntimeLaunchResult, + memberName: string +): string[] { + const member = result.members[memberName]; + return [...(member?.diagnostics ?? []), member?.hardFailureReason, ...result.diagnostics].filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0 + ); +} + +export function collectOpenCodeSecondaryLaneFailureDiagnostics( + result: TeamRuntimeLaunchResult, + memberName: string, + prefixDiagnostics: readonly string[] +): string[] { + const diagnostics = [ + ...prefixDiagnostics, + ...collectRuntimeLaunchFailureDiagnostics(result, memberName), + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + return diagnostics.length > 0 ? diagnostics : ['OpenCode bridge reported member launch failure']; +} + +export function isReconciliableOpenCodeUnknownOutcome(diagnostics: readonly string[]): boolean { + return diagnostics.some((diagnostic) => + /outcome must be reconciled before retry/i.test(diagnostic) + ); +} + +export function isDefinitiveOpenCodePreLaunchFailure( + result: TeamRuntimeLaunchResult, + memberName: string +): boolean { + const member = result.members[memberName]; + if (!member) { + return false; + } + const hardFailed = member.launchState === 'failed_to_start' || member.hardFailure === true; + if (!hardFailed) { + return false; + } + const runtimeMaterialized = + member.agentToolAccepted || + member.runtimeAlive || + member.bootstrapConfirmed || + (typeof member.sessionId === 'string' && member.sessionId.trim().length > 0) || + (typeof member.runtimePid === 'number' && + Number.isFinite(member.runtimePid) && + member.runtimePid > 0); + if (runtimeMaterialized) { + return false; + } + return !isReconciliableOpenCodeUnknownOutcome( + collectRuntimeLaunchFailureDiagnostics(result, memberName) + ); +} + +export function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean { + if (typeof sessionId !== 'string') { + return false; + } + const trimmed = sessionId.trim(); + return trimmed.length > 0 && !trimmed.startsWith('failed:'); +} + +export function hasMaterializedOpenCodeRuntimeForBootstrap( + member: TeamRuntimeMemberLaunchEvidence | undefined +): member is TeamRuntimeMemberLaunchEvidence { + if (!member) { + return false; + } + if (isMaterializedOpenCodeSessionId(member.sessionId)) { + return true; + } + return ( + hasOpenCodeRuntimeLivenessMarker(member) && + typeof member.runtimePid === 'number' && + Number.isFinite(member.runtimePid) && + member.runtimePid > 0 + ); +} + +export function isRecoverableOpenCodeBootstrapPendingLaunchResult( + result: TeamRuntimeLaunchResult, + memberName: string +): boolean { + const member = result.members[memberName]; + if (!hasMaterializedOpenCodeRuntimeForBootstrap(member)) { + return false; + } + if (member.bootstrapConfirmed || member.launchState === 'confirmed_alive') { + return false; + } + if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { + return false; + } + return hasRecoverableOpenCodeBootstrapDiagnostic( + collectRuntimeLaunchFailureDiagnostics(result, memberName) + ); +} + +export function summarizeRuntimeLaunchResultMembers( + members: Record +): TeamLaunchAggregateState { + const values = Object.values(members); + if ( + values.some((member) => member.launchState === 'failed_to_start' || member.hardFailure === true) + ) { + return 'partial_failure'; + } + if (values.length > 0 && values.every((member) => member.launchState === 'confirmed_alive')) { + return 'clean_success'; + } + return 'partial_pending'; +} + +export function normalizeRecoverableOpenCodeBootstrapPendingLaunchResult( + result: TeamRuntimeLaunchResult, + memberName: string, + diagnostics: readonly string[] +): TeamRuntimeLaunchResult { + const member = result.members[memberName]; + if (!member) { + return result; + } + const memberDiagnostics = Array.from( + new Set([ + ...(member.diagnostics ?? []), + OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC, + isExplicitLegacyOpenCodeBootstrap(member) + ? 'OpenCode runtime session materialized; waiting for runtime_bootstrap_checkin.' + : OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC, + ...diagnostics, + ]) + ); + const normalizedMember: TeamRuntimeMemberLaunchEvidence = { + ...member, + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds: undefined, + livenessKind: + member.livenessKind === 'confirmed_bootstrap' + ? 'runtime_process' + : (member.livenessKind ?? 'runtime_process'), + runtimeDiagnostic: + member.runtimeDiagnostic ?? + 'OpenCode runtime process detected; waiting for bootstrap check-in.', + runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'info', + diagnostics: memberDiagnostics, + }; + const members = { + ...result.members, + [memberName]: normalizedMember, + }; + const teamLaunchState = summarizeRuntimeLaunchResultMembers(members); + return { + ...result, + launchPhase: teamLaunchState === 'clean_success' ? result.launchPhase : 'active', + teamLaunchState, + members, + diagnostics: Array.from(new Set([...result.diagnostics, ...memberDiagnostics])), + }; +} + +export function buildOpenCodeUncommittedBootstrapDiagnostic(storage: { + manifestEntryCount: number | null; + manifestUpdatedAt: string | null; + fileNames: string[]; +}): string[] { + return [ + OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC, + `OpenCode lane manifest entries: ${storage.manifestEntryCount ?? 0}`, + ...(storage.manifestUpdatedAt + ? [`OpenCode lane manifest updated at: ${storage.manifestUpdatedAt}`] + : []), + storage.fileNames.length > 0 + ? `OpenCode lane files: ${storage.fileNames.slice(0, 8).join(', ')}` + : 'OpenCode lane files: none', + ]; +} + +export function downgradeUncommittedOpenCodeBootstrapEvidence( + evidence: TeamRuntimeMemberLaunchEvidence, + diagnostics: readonly string[] +): TeamRuntimeMemberLaunchEvidence { + const hasRuntimeHandle = hasOpenCodeRuntimeHandle(evidence); + return { + ...evidence, + launchState: hasRuntimeHandle ? 'runtime_pending_bootstrap' : 'starting', + agentToolAccepted: hasRuntimeHandle, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + livenessKind: hasRuntimeHandle + ? evidence.livenessKind === 'confirmed_bootstrap' + ? 'runtime_process_candidate' + : (evidence.livenessKind ?? 'runtime_process_candidate') + : 'registered_only', + runtimeDiagnostic: hasRuntimeHandle + ? 'OpenCode runtime handle is present, but bootstrap evidence was not committed.' + : 'OpenCode bootstrap confirmation was not committed to lane runtime evidence.', + runtimeDiagnosticSeverity: 'warning', + diagnostics: Array.from(new Set([...evidence.diagnostics, ...diagnostics])), + }; +} + +export function promoteCommittedOpenCodeAppManagedBootstrapEvidence( + evidence: TeamRuntimeMemberLaunchEvidence +): TeamRuntimeMemberLaunchEvidence { + return { + ...evidence, + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + hardFailureReason: undefined, + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: + 'OpenCode app-managed bootstrap evidence was committed and read back by the desktop app.', + runtimeDiagnosticSeverity: 'info', + diagnostics: appendDiagnosticOnce( + evidence.diagnostics, + 'OpenCode app-managed bootstrap evidence committed and read back.' + ), + }; +} + +export function hasOpenCodeRuntimeHandle( + value: + | Pick + | Pick + | undefined +): boolean { + if (!value) { + return false; + } + const runtimePid = + typeof value.runtimePid === 'number' && + Number.isFinite(value.runtimePid) && + value.runtimePid > 0; + const runtimeSessionId = (value as { runtimeSessionId?: unknown }).runtimeSessionId; + const runtimeEvidenceSessionId = (value as { sessionId?: unknown }).sessionId; + const sessionId = + (typeof runtimeSessionId === 'string' && runtimeSessionId.trim().length > 0) || + (typeof runtimeEvidenceSessionId === 'string' && runtimeEvidenceSessionId.trim().length > 0); + return runtimePid || sessionId; +} + +export function hasOpenCodeRuntimeLivenessMarker( + value: Pick | undefined +): boolean { + return ( + value?.livenessKind === 'runtime_process' || + value?.livenessKind === 'runtime_process_candidate' || + value?.livenessKind === 'permission_blocked' + ); +} + +export function hasOpenCodeRuntimeEntryHandle( + value: + | Pick + | undefined + | null +): boolean { + if (!value) { + return false; + } + const pid = typeof value.pid === 'number' && Number.isFinite(value.pid) && value.pid > 0; + const runtimePid = + typeof value.runtimePid === 'number' && + Number.isFinite(value.runtimePid) && + value.runtimePid > 0; + const runtimeSessionId = + typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0; + return pid || runtimePid || runtimeSessionId || hasOpenCodeRuntimeLivenessMarker(value); +} + +export function isRecoverablePersistedOpenCodeRuntimeCandidate( + member: PersistedTeamLaunchMemberState | undefined | null +): boolean { + if (!member || member.skippedForLaunch) { + return false; + } + if (!isPersistedOpenCodeSecondaryLaneMember(member)) { + return false; + } + const hasPendingPermission = (member.pendingPermissionRequestIds?.length ?? 0) > 0; + return ( + member.agentToolAccepted === true && (hasOpenCodeRuntimeHandle(member) || hasPendingPermission) + ); +} + +export function normalizeIsoTimestamp(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null; +} + +function selectEarliestIsoTimestamp(values: readonly unknown[]): string | undefined { + let selected: { value: string; timeMs: number } | null = null; + for (const value of values) { + const normalized = normalizeIsoTimestamp(value); + if (!normalized) { + continue; + } + const timeMs = Date.parse(normalized); + if (!selected || timeMs < selected.timeMs) { + selected = { value: normalized, timeMs }; + } + } + return selected?.value; +} + +function extractOpenCodeMemberSessionRecordedAt( + diagnostics: readonly string[] | undefined +): string[] { + return (diagnostics ?? []).flatMap((diagnostic) => { + const match = OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN.exec(diagnostic); + return match?.[1] ? [match[1]] : []; + }); +} + +export function resolveOpenCodeBootstrapAcceptedAt( + member: Pick +): string | undefined { + return selectEarliestIsoTimestamp([ + member.firstSpawnAcceptedAt, + ...extractOpenCodeMemberSessionRecordedAt(member.diagnostics), + ]); +} + +function hasOpenCodeSecondaryFatalBootstrapDiagnostic( + member: Pick< + PersistedTeamLaunchMemberState, + 'diagnostics' | 'runtimeDiagnostic' | 'hardFailureReason' + > +): boolean { + const text = [member.runtimeDiagnostic, member.hardFailureReason, ...(member.diagnostics ?? [])] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') + .toLowerCase(); + return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text); +} + +export function selectOpenCodeSecondaryBootstrapStallDiagnostic( + values: readonly unknown[] +): string | null { + const normalizedValues = values + .filter((value): value is string => typeof value === 'string') + .map((value) => normalizeOpenCodePersistedFailureReason(value)) + .filter((value): value is string => typeof value === 'string' && value.length > 0); + + const runtimeCheckinDiagnostic = normalizedValues.find((value) => + value.toLowerCase().includes('runtime_bootstrap_checkin') + ); + if (runtimeCheckinDiagnostic) { + return runtimeCheckinDiagnostic; + } + + const memberBriefingDiagnostic = normalizedValues.find((value) => + value.toLowerCase().includes('member_briefing') + ); + if (memberBriefingDiagnostic) { + return `${memberBriefingDiagnostic}; runtime_bootstrap_checkin did not complete after 5 min.`; + } + + return null; +} + +export function getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted( + member: PersistedTeamLaunchMemberState +): string { + if (!isExplicitLegacyOpenCodeBootstrap(member)) { + return OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC; + } + + const selected = selectOpenCodeSecondaryBootstrapStallDiagnostic([ + member.runtimeDiagnostic, + ...(member.diagnostics ?? []), + member.hardFailureReason, + ]); + if (selected) { + return selected; + } + + return 'OpenCode bootstrap did not complete runtime_bootstrap_checkin after 5 min.'; +} + +export function shouldMarkPersistedOpenCodeBootstrapStalled( + member: PersistedTeamLaunchMemberState, + nowMs: number +): boolean { + if (!isPersistedOpenCodeSecondaryLaneMember(member)) { + return false; + } + if ( + member.launchState !== 'runtime_pending_bootstrap' || + member.bootstrapConfirmed === true || + member.hardFailure === true || + member.skippedForLaunch === true || + (member.pendingPermissionRequestIds?.length ?? 0) > 0 + ) { + return false; + } + if (hasOpenCodeSecondaryFatalBootstrapDiagnostic(member)) { + return false; + } + const acceptedAt = resolveOpenCodeBootstrapAcceptedAt(member); + const acceptedAtMs = acceptedAt ? Date.parse(acceptedAt) : NaN; + if (!Number.isFinite(acceptedAtMs) || nowMs - acceptedAtMs < MEMBER_BOOTSTRAP_STALL_MS) { + return false; + } + return ( + hasOpenCodeRuntimeHandle(member) || + hasOpenCodeRuntimeLivenessMarker(member) || + hasRecoverableOpenCodeBootstrapDiagnostic( + [member.runtimeDiagnostic, ...(member.diagnostics ?? [])].filter( + (value): value is string => typeof value === 'string' + ) + ) + ); +} + +export function isRecoverablePersistedOpenCodeTerminalRuntimeCandidate( + member: PersistedTeamLaunchMemberState | undefined | null +): boolean { + return ( + isRecoverablePersistedOpenCodeRuntimeCandidate(member) && + member?.launchState === 'failed_to_start' && + member.hardFailure === true && + hasOpenCodeRuntimeHandle(member) + ); +} + +export function isRecoverableOpenCodeRuntimeEvidence( + evidence: TeamRuntimeMemberLaunchEvidence | undefined | null +): evidence is TeamRuntimeMemberLaunchEvidence { + if (!evidence) { + return false; + } + return ( + evidence.runtimeAlive === true || + evidence.bootstrapConfirmed === true || + (evidence.pendingPermissionRequestIds?.length ?? 0) > 0 || + hasOpenCodeRuntimeHandle(evidence) || + (evidence.agentToolAccepted === true && hasOpenCodeRuntimeLivenessMarker(evidence)) + ); +} + +export function isBootstrapMemberEvidenceCurrentForMember( + current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string }, + bootstrapMember: Pick< + PersistedTeamLaunchMemberState, + 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'lastRuntimeAliveAt' | 'lastEvaluatedAt' + >, + evidenceKind: 'acceptance' | 'confirmation' +): boolean { + const bootstrapFirstSpawnAcceptedMs = Date.parse(bootstrapMember.firstSpawnAcceptedAt ?? ''); + const bootstrapLastEvaluatedMs = Date.parse(bootstrapMember.lastEvaluatedAt ?? ''); + const hasDurableBootstrapSpawnAcceptedAt = + Number.isFinite(bootstrapFirstSpawnAcceptedMs) && + (!Number.isFinite(bootstrapLastEvaluatedMs) || + bootstrapFirstSpawnAcceptedMs <= bootstrapLastEvaluatedMs); + const evidenceAt = + evidenceKind === 'confirmation' + ? (bootstrapMember.lastHeartbeatAt ?? + bootstrapMember.lastRuntimeAliveAt ?? + bootstrapMember.lastEvaluatedAt) + : hasDurableBootstrapSpawnAcceptedAt + ? bootstrapMember.firstSpawnAcceptedAt + : bootstrapMember.lastEvaluatedAt; + const evidenceMs = Date.parse(evidenceAt ?? ''); + if (!Number.isFinite(evidenceMs)) { + return false; + } + const firstSpawnAcceptedMs = Date.parse(current.firstSpawnAcceptedAt ?? ''); + const lastEvaluatedMs = Date.parse(current.lastEvaluatedAt ?? ''); + const hasDurableSpawnBoundary = + Number.isFinite(firstSpawnAcceptedMs) && + (!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs); + const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN; + return !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs; +} diff --git a/src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts b/src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts new file mode 100644 index 00000000..1b53bc35 --- /dev/null +++ b/src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts @@ -0,0 +1,172 @@ +import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; + +import { resolveTeamProviderId } from '../../runtime/providerRuntimeEnv'; + +import type { GeminiRuntimeAuthState } from '../../runtime/geminiRuntimeAuth'; +import type { ProviderModelLaunchIdentity, TeamCreateRequest, TeamProviderId } from '@shared/types'; + +export interface PromptSizeSummary { + chars: number; + lines: number; +} + +export interface RuntimeLaunchLogger { + info(message: string): void; +} + +export function getAnthropicFastModeDefault(): boolean { + return ( + ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true + ); +} + +export function getTeamProviderLabel(providerId: TeamProviderId): string { + switch (providerId) { + case 'opencode': + return 'OpenCode'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + default: + return 'Anthropic'; + } +} + +export function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null { + const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends; + switch (providerId) { + case 'opencode': + return null; + case 'gemini': + return runtimeConfig.gemini; + case 'codex': + return migrateProviderBackendId('codex', runtimeConfig.codex) ?? 'codex-native'; + case 'anthropic': + default: + return null; + } +} + +export function buildRuntimeLaunchWarning( + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' + >, + env: NodeJS.ProcessEnv, + options?: { + geminiRuntimeAuth?: GeminiRuntimeAuthState | null; + promptSize?: PromptSizeSummary | null; + expectedMembersCount?: number; + } +): string { + const providerId = resolveTeamProviderId(request.providerId); + const providerLabel = getTeamProviderLabel(providerId); + const modelLabel = request.model?.trim() || 'default'; + const effortLabel = request.effort ?? 'default'; + const fastLabel = + providerId === 'anthropic' + ? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}` + : providerId === 'codex' + ? `, fast ${request.fastMode ?? 'inherit:off'}` + : ''; + const backend = + migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || + getConfiguredRuntimeBackend(providerId); + const flags: string[] = []; + if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI'); + if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI'); + if (env.CLAUDE_CODE_ENTRY_PROVIDER) { + flags.push(`ENTRY_PROVIDER=${env.CLAUDE_CODE_ENTRY_PROVIDER}`); + } + if (env.CLAUDE_CODE_GEMINI_BACKEND) { + flags.push(`GEMINI_BACKEND=${env.CLAUDE_CODE_GEMINI_BACKEND}`); + } + if (env.CLAUDE_CODE_CODEX_BACKEND) { + flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`); + } + if (env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES === '1') { + flags.push('FORCE_PROCESS_TEAMMATES'); + } + const backendPart = backend ? `, backend ${backend}` : ''; + const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : ''; + const geminiAuth = options?.geminiRuntimeAuth; + const authPart = + providerId === 'gemini' && geminiAuth + ? `, auth ${geminiAuth.authMethod ?? 'none'}/${geminiAuth.resolvedBackend}` + : ''; + const promptSize = options?.promptSize; + const promptPart = promptSize + ? `, prompt ${promptSize.chars.toLocaleString('en-US')} chars/${promptSize.lines} lines` + : ''; + const membersPart = + typeof options?.expectedMembersCount === 'number' + ? `, members ${options.expectedMembersCount}` + : ''; + return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${fastLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`; +} + +export function logRuntimeLaunchSnapshot( + logger: RuntimeLaunchLogger, + teamName: string, + claudePath: string, + args: string[], + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' + >, + env: NodeJS.ProcessEnv, + options?: { + geminiRuntimeAuth?: GeminiRuntimeAuthState | null; + promptSize?: PromptSizeSummary | null; + expectedMembersCount?: number; + launchIdentity?: ProviderModelLaunchIdentity | null; + } +): void { + const providerId = resolveTeamProviderId(request.providerId); + const snapshot = { + providerId, + providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null, + model: request.model ?? null, + effort: request.effort ?? null, + fastMode: request.fastMode ?? null, + configuredBackend: + migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || + getConfiguredRuntimeBackend(providerId), + promptSize: options?.promptSize ?? null, + expectedMembersCount: options?.expectedMembersCount ?? null, + launchIdentity: options?.launchIdentity ?? null, + geminiRuntimeAuth: + providerId === 'gemini' + ? { + authenticated: options?.geminiRuntimeAuth?.authenticated ?? null, + authMethod: options?.geminiRuntimeAuth?.authMethod ?? null, + resolvedBackend: options?.geminiRuntimeAuth?.resolvedBackend ?? null, + projectId: options?.geminiRuntimeAuth?.projectId ?? null, + statusMessage: options?.geminiRuntimeAuth?.statusMessage ?? null, + } + : null, + env: { + CLAUDE_CODE_USE_GEMINI: env.CLAUDE_CODE_USE_GEMINI ?? null, + CLAUDE_CODE_USE_OPENAI: env.CLAUDE_CODE_USE_OPENAI ?? null, + CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null, + CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null, + CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null, + CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES ?? null, + CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null, + CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null, + }, + args, + claudePath, + }; + logger.info(`[${teamName}] Launch runtime snapshot ${JSON.stringify(snapshot)}`); +} + +export function getPromptSizeSummary(prompt: string): PromptSizeSummary { + return { + chars: prompt.length, + lines: prompt.length === 0 ? 0 : prompt.split(/\r?\n/g).length, + }; +} diff --git a/test/main/services/team/TeamProvisioningCliExitPresentation.test.ts b/test/main/services/team/TeamProvisioningCliExitPresentation.test.ts new file mode 100644 index 00000000..abc9d4b4 --- /dev/null +++ b/test/main/services/team/TeamProvisioningCliExitPresentation.test.ts @@ -0,0 +1,135 @@ +import { + buildCliExitFailurePresentation, + buildCombinedLogs, + buildDeterministicBootstrapExitFailure, + buildSanitizedCliExitError, + type CliExitPresentationRun, + formatPendingBootstrapMemberNames, + parseCliLogLinesFromText, +} from '@main/services/team/provisioning/TeamProvisioningCliExitPresentation'; +import { describe, expect, it } from 'vitest'; + +function run(overrides: Partial = {}): CliExitPresentationRun { + return { + stdoutBuffer: '', + stderrBuffer: '', + claudeLogLines: [], + deterministicBootstrap: false, + deterministicBootstrapMemberSpawnSeen: false, + expectedMembers: [], + memberSpawnStatuses: new Map(), + ...overrides, + }; +} + +describe('TeamProvisioningCliExitPresentation', () => { + it('combines stdout and stderr with stable stream markers', () => { + expect(buildCombinedLogs('', '')).toBe(''); + expect(buildCombinedLogs(' stdout only ', '')).toBe('stdout only'); + expect(buildCombinedLogs('', ' stderr only ')).toBe('stderr only'); + expect(buildCombinedLogs('out', 'err')).toBe('[stdout]\nout\n\n[stderr]\nerr'); + }); + + it('parses stream markers and ignores blank log lines', () => { + expect(parseCliLogLinesFromText('[stdout]\nhello\n\n[stderr]\nboom')).toEqual([ + { stream: 'stdout', text: 'hello' }, + { stream: 'stderr', text: 'boom' }, + ]); + }); + + it('extracts structured CLI errors while filtering setup noise', () => { + const sanitized = buildSanitizedCliExitError( + run({ + claudeLogLines: [ + '[stdout]', + '{"type":"system","subtype":"init","message":"ignore"}', + '[stderr]', + '{"type":"error","message":"Invalid API key"}', + '{"type":"result","subtype":"error","error":"Quota exceeded"}', + 'TodoWrite hook_progress should stay hidden', + 'plain stderr failure', + ], + }) + ); + + expect(sanitized).toBe('Invalid API key\nQuota exceeded\nplain stderr failure'); + }); + + it('adds final partial buffer errors when line history already exists', () => { + expect( + buildSanitizedCliExitError( + run({ + claudeLogLines: ['[stderr]', 'first failure'], + stderrBuffer: 'first failure\nsecond failure without newline', + }) + ) + ).toBe('first failure\nsecond failure without newline'); + }); + + it('reports login guidance before generic sanitized errors', () => { + expect( + buildCliExitFailurePresentation( + run({ stderrBuffer: 'Please run /login to authenticate' }), + 1, + { cliCommandLabel: 'Claude CLI' } + ).error + ).toContain('Claude CLI reports it is not authenticated'); + }); + + it('formats deterministic bootstrap failures by observed stage', () => { + expect( + buildDeterministicBootstrapExitFailure(run({ deterministicBootstrap: true })).error + ).toContain('before deterministic team bootstrap started'); + + expect( + buildDeterministicBootstrapExitFailure( + run({ + deterministicBootstrap: true, + lastDeterministicBootstrapEvent: 'team_bootstrap', + lastDeterministicBootstrapPhase: 'planning', + }) + ).error + ).toContain('Last bootstrap event: team_bootstrap/planning'); + }); + + it('summarizes pending bootstrap members with a stable cap', () => { + expect( + formatPendingBootstrapMemberNames( + run({ + expectedMembers: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], + memberSpawnStatuses: new Map([ + ['a', { bootstrapConfirmed: true }], + ['b', { bootstrapConfirmed: false }], + ]), + }) + ) + ).toBe('b, c, d, e, f, g and 1 more'); + }); + + it('falls back to deterministic or generic exit presentations when logs are not useful', () => { + expect( + buildCliExitFailurePresentation( + run({ + deterministicBootstrap: true, + lastDeterministicBootstrapEvent: 'team_bootstrap', + deterministicBootstrapMemberSpawnSeen: true, + expectedMembers: ['lead', 'worker'], + memberSpawnStatuses: new Map([['lead', { bootstrapConfirmed: true }]]), + }), + 1, + { cliCommandLabel: 'Codex runtime' } + ) + ).toEqual({ + message: 'Launch bootstrap was not confirmed', + error: + 'Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: worker.', + }); + + expect( + buildCliExitFailurePresentation(run(), 1, { cliCommandLabel: 'Claude CLI' }).error + ).toContain('Claude CLI exited with code 1 without user-facing stdout/stderr'); + expect( + buildCliExitFailurePresentation(run(), null, { cliCommandLabel: 'Claude CLI' }).error + ).toBe('Claude CLI exited with code unknown'); + }); +}); diff --git a/test/main/services/team/TeamProvisioningDirectRestart.test.ts b/test/main/services/team/TeamProvisioningDirectRestart.test.ts new file mode 100644 index 00000000..b1fee3a8 --- /dev/null +++ b/test/main/services/team/TeamProvisioningDirectRestart.test.ts @@ -0,0 +1,146 @@ +import { + buildDirectTmuxRestartCommand, + buildDirectTmuxRestartEnvAssignments, + hasAnthropicCompatibleAuthTokenEnv, + isAnthropicCompatibleBaseUrl, + isInteractiveShellCommand, + shellQuote, +} from '@main/services/team/provisioning/TeamProvisioningDirectRestart'; +import { describe, expect, it } from 'vitest'; + +describe('TeamProvisioningDirectRestart', () => { + it('quotes shell values without losing apostrophes or empty strings', () => { + expect(shellQuote('')).toBe("''"); + expect(shellQuote('/tmp/demo path')).toBe("'/tmp/demo path'"); + expect(shellQuote("worker's path")).toBe("'worker'\\''s path'"); + }); + + it('detects interactive shell pane commands by basename', () => { + expect(isInteractiveShellCommand('/bin/zsh')).toBe(true); + expect(isInteractiveShellCommand(' FISH ')).toBe(true); + expect(isInteractiveShellCommand('node')).toBe(false); + expect(isInteractiveShellCommand(undefined)).toBe(false); + }); + + it('classifies Anthropic-compatible base URLs without accepting first-party or credential URLs', () => { + expect(isAnthropicCompatibleBaseUrl('http://localhost:1234')).toBe(true); + expect(isAnthropicCompatibleBaseUrl('https://proxy.example.test')).toBe(true); + expect(isAnthropicCompatibleBaseUrl('https://api.anthropic.com')).toBe(false); + expect(isAnthropicCompatibleBaseUrl('https://api-staging.anthropic.com')).toBe(false); + expect(isAnthropicCompatibleBaseUrl('http://token@localhost:1234')).toBe(false); + expect(isAnthropicCompatibleBaseUrl('not a url')).toBe(false); + expect(isAnthropicCompatibleBaseUrl('')).toBe(false); + }); + + it('requires both compatible base URL and auth token for compatible auth token env', () => { + expect( + hasAnthropicCompatibleAuthTokenEnv({ + ANTHROPIC_BASE_URL: 'http://localhost:1234', + ANTHROPIC_AUTH_TOKEN: 'local-token', + }) + ).toBe(true); + expect( + hasAnthropicCompatibleAuthTokenEnv({ + ANTHROPIC_BASE_URL: 'http://localhost:1234', + ANTHROPIC_AUTH_TOKEN: ' ', + }) + ).toBe(false); + expect( + hasAnthropicCompatibleAuthTokenEnv({ + ANTHROPIC_BASE_URL: 'https://api.anthropic.com', + ANTHROPIC_AUTH_TOKEN: 'stale-token', + }) + ).toBe(false); + }); + + it('preserves provider-specific direct restart env while resetting provider selection flags', () => { + const assignments = buildDirectTmuxRestartEnvAssignments( + { + CODEX_HOME: '/tmp/codex home', + CLAUDE_CODE_USE_GEMINI: '1', + CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + }, + 'codex' + ); + + expect(assignments).toContain("CLAUDECODE='1'"); + expect(assignments).toContain("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS='1'"); + expect(assignments).toContain("CODEX_HOME='/tmp/codex home'"); + expect(assignments).toContain("CLAUDE_CODE_USE_GEMINI=''"); + expect(assignments).toContain("CLAUDE_CODE_ENTRY_PROVIDER='codex'"); + expect(assignments).toContain("CLAUDE_CODE_CODEX_BACKEND='codex-native'"); + expect(assignments).toContain("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST='1'"); + }); + + it('preserves Anthropic-compatible tokens but blanks stale first-party auth tokens', () => { + const compatibleAssignments = buildDirectTmuxRestartEnvAssignments( + { + ANTHROPIC_BASE_URL: ' http://localhost:1234 ', + ANTHROPIC_AUTH_TOKEN: ' local-token ', + ANTHROPIC_API_KEY: '', + }, + 'anthropic' + ); + + expect(compatibleAssignments).toContain("ANTHROPIC_BASE_URL='http://localhost:1234'"); + expect(compatibleAssignments).toContain("ANTHROPIC_AUTH_TOKEN='local-token'"); + expect(compatibleAssignments).toContain("ANTHROPIC_API_KEY=''"); + + const firstPartyAssignments = buildDirectTmuxRestartEnvAssignments( + { + ANTHROPIC_BASE_URL: 'https://api.anthropic.com', + ANTHROPIC_AUTH_TOKEN: 'stale-token', + }, + 'anthropic' + ); + + expect(firstPartyAssignments).toContain("ANTHROPIC_BASE_URL='https://api.anthropic.com'"); + expect(firstPartyAssignments).toContain("ANTHROPIC_AUTH_TOKEN=''"); + expect(firstPartyAssignments).not.toContain('stale-token'); + }); + + it('blanks competing Anthropic helper auth carriers for direct restart helper mode', () => { + const assignments = buildDirectTmuxRestartEnvAssignments( + { + CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper', + CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH: + '/tmp/team-runtime-auth/demo/runtime-settings-anthropic.json', + ANTHROPIC_API_KEY: 'sk-ant-direct-restart-should-not-leak', + ANTHROPIC_AUTH_TOKEN: 'direct-restart-token-should-not-leak', + CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR: '3', + CLAUDE_CODE_OAUTH_TOKEN: 'direct-restart-oauth-token-should-not-leak', + CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: '4', + }, + 'anthropic' + ); + + expect(assignments).toContain("CLAUDE_TEAM_ANTHROPIC_AUTH_MODE='api_key_helper'"); + expect(assignments).toContain( + "CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH='/tmp/team-runtime-auth/demo/runtime-settings-anthropic.json'" + ); + expect(assignments).toContain("ANTHROPIC_API_KEY=''"); + expect(assignments).toContain("ANTHROPIC_AUTH_TOKEN=''"); + expect(assignments).toContain("CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR=''"); + expect(assignments).toContain("CLAUDE_CODE_OAUTH_TOKEN=''"); + expect(assignments).toContain("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=''"); + expect(assignments).not.toContain('sk-ant-direct-restart-should-not-leak'); + expect(assignments).not.toContain('direct-restart-token-should-not-leak'); + expect(assignments).not.toContain('direct-restart-oauth-token-should-not-leak'); + }); + + it('builds a restart command that preserves cwd, binary and args quoting', () => { + const command = buildDirectTmuxRestartCommand({ + cwd: '/tmp/team work', + env: { CODEX_HOME: '/tmp/codex' }, + providerId: 'codex', + binaryPath: '/usr/local/bin/claude', + args: ['--model', "gpt worker's model"], + }); + + expect(command).toContain("cd '/tmp/team work' && env"); + expect(command).toContain("CODEX_HOME='/tmp/codex'"); + expect(command).toContain("'/usr/local/bin/claude' '--model' 'gpt worker'\\''s model'"); + expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__:%s'); + }); +}); diff --git a/test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts b/test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts new file mode 100644 index 00000000..89a67b78 --- /dev/null +++ b/test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts @@ -0,0 +1,75 @@ +import { + assertDeterministicBootstrapPrimaryMemberLimit, + assertOpenCodeNotLaunchedThroughLegacyProvisioning, + buildLargeDeterministicBootstrapWarning, + getMixedLaunchFallbackRecoveryError, + getOpenCodeMixedProviderProvisioningError, + isPureOpenCodeProvisioningRequest, + mergeProvisioningWarnings, +} from '@main/services/team/provisioning/TeamProvisioningLaunchCompatibility'; +import { describe, expect, it } from 'vitest'; + +describe('TeamProvisioningLaunchCompatibility', () => { + it('classifies pure OpenCode legacy provisioning requests', () => { + expect( + isPureOpenCodeProvisioningRequest({ + providerId: 'opencode', + members: [{ providerId: 'opencode' }, {}], + }) + ).toBe(true); + + expect( + isPureOpenCodeProvisioningRequest({ + providerId: 'codex', + members: [{ providerId: 'opencode' }], + }) + ).toBe(false); + }); + + it('blocks pure OpenCode legacy stream-json launch but allows supported side-lane mixed teams', () => { + expect(() => + assertOpenCodeNotLaunchedThroughLegacyProvisioning({ + providerId: 'opencode', + members: [{ providerId: 'opencode' }], + }) + ).toThrow('OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path'); + + expect(() => + assertOpenCodeNotLaunchedThroughLegacyProvisioning({ + providerId: 'codex', + members: [{ providerId: 'opencode' }, { providerId: 'codex' }], + }) + ).not.toThrow(); + }); + + it('keeps unsupported OpenCode-led mixed teams blocked with planner diagnostics', () => { + expect(() => + assertOpenCodeNotLaunchedThroughLegacyProvisioning({ + providerId: 'opencode', + members: [{ providerId: 'anthropic' }, { providerId: 'opencode' }], + }) + ).toThrow('Mixed teams with an OpenCode lead are not supported'); + }); + + it('deduplicates provisioning warnings while preserving latest ordering', () => { + expect(mergeProvisioningWarnings(undefined, null)).toBeUndefined(); + expect(mergeProvisioningWarnings(['alpha', 'beta'], 'alpha')).toEqual(['beta', 'alpha']); + expect(mergeProvisioningWarnings(['alpha'], 'beta')).toEqual(['alpha', 'beta']); + }); + + it('bounds deterministic bootstrap team size warnings and hard limits', () => { + expect(buildLargeDeterministicBootstrapWarning(8)).toBeNull(); + expect(buildLargeDeterministicBootstrapWarning(9)).toContain('Large Codex team launch: 9'); + expect(() => assertDeterministicBootstrapPrimaryMemberLimit(20)).not.toThrow(); + expect(() => assertDeterministicBootstrapPrimaryMemberLimit(21)).toThrow( + 'supports up to 20 primary teammates' + ); + }); + + it('keeps user-facing compatibility error copy stable', () => { + expect(getOpenCodeMixedProviderProvisioningError()).toContain( + 'outside the current support scope' + ); + expect(getMixedLaunchFallbackRecoveryError()).toContain('missing stable member metadata'); + }); +}); diff --git a/test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts b/test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts new file mode 100644 index 00000000..d05f9957 --- /dev/null +++ b/test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts @@ -0,0 +1,324 @@ +import { + buildLaunchDiagnosticsFromRun, + buildWorkspaceTrustPreflightLaunchDiagnostic, + mentionsProcessTableUnavailable, + mergeLaunchDiagnosticItem, +} from '@main/services/team/provisioning/TeamProvisioningLaunchDiagnostics'; +import { describe, expect, it } from 'vitest'; + +import type { WorkspaceTrustExecutionResult } from '@features/workspace-trust/main'; +import type { MemberSpawnStatusEntry, TeamLaunchDiagnosticItem } from '@shared/types'; + +const NOW = '2026-05-24T00:00:00.000Z'; +const nowIso = () => NOW; + +function spawnEntry(overrides: Partial): MemberSpawnStatusEntry { + return { + status: 'waiting', + launchState: 'starting', + updatedAt: NOW, + ...overrides, + }; +} + +function buildRun(entries: Array<[string, Partial]>, isLaunch = true) { + return { + isLaunch, + memberSpawnStatuses: new Map( + entries.map(([memberName, entry]) => [memberName, spawnEntry(entry)]) + ), + }; +} + +function workspaceTrustExecution( + overrides: Partial +): WorkspaceTrustExecutionResult { + return { + id: 'claude-pty-workspace-trust', + provider: 'claude', + status: 'ok', + workspaceIds: ['workspace-1'], + ...overrides, + }; +} + +describe('TeamProvisioningLaunchDiagnostics', () => { + it('skips non-launch and empty launch runs', () => { + expect(buildLaunchDiagnosticsFromRun(buildRun([], false), { nowIso })).toBeUndefined(); + expect(buildLaunchDiagnosticsFromRun(buildRun([]), { nowIso })).toBeUndefined(); + expect( + buildLaunchDiagnosticsFromRun({ isLaunch: true, memberSpawnStatuses: undefined }, { nowIso }) + ).toBeUndefined(); + }); + + it('projects member spawn status entries into launch diagnostics without changing priority order', () => { + const diagnostics = buildLaunchDiagnosticsFromRun( + buildRun([ + ['Lead', { launchState: 'confirmed_alive' }], + [ + 'WorkerA', + { + launchState: 'failed_to_start', + error: 'fallback error', + hardFailureReason: 'hard failure reason', + agentToolAccepted: true, + }, + ], + [ + 'WorkerB', + { + launchState: 'runtime_pending_permission', + runtimeDiagnostic: 'waiting for user approval', + }, + ], + [ + 'WorkerC', + { + bootstrapStalled: true, + runtimeDiagnostic: 'bootstrap deadline exceeded', + livenessKind: 'runtime_process', + }, + ], + [ + 'WorkerD', + { + runtimeDiagnostic: 'process table temporarily unavailable', + livenessKind: 'shell_only', + }, + ], + ['WorkerE', { livenessKind: 'shell_only', runtimeDiagnostic: 'tmux pane only' }], + [ + 'WorkerF', + { + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'process found but bootstrap missing', + }, + ], + ['WorkerG', { livenessKind: 'runtime_process', runtimeDiagnostic: 'process found' }], + ['WorkerH', { livenessKind: 'registered_only', runtimeDiagnostic: 'registered only' }], + ['WorkerI', { livenessKind: 'stale_metadata', runtimeDiagnostic: 'stale metadata' }], + ['WorkerJ', { livenessKind: 'not_found', runtimeDiagnostic: 'no runtime' }], + ['WorkerK', { agentToolAccepted: true, runtimeDiagnostic: 'spawn accepted' }], + ['WorkerL', {}], + ]), + { nowIso } + ); + + expect(diagnostics).toEqual([ + { + id: 'Lead:bootstrap_confirmed', + memberName: 'Lead', + severity: 'info', + code: 'bootstrap_confirmed', + label: 'Lead - bootstrap confirmed', + observedAt: NOW, + }, + { + id: 'WorkerA:bootstrap_stalled', + memberName: 'WorkerA', + severity: 'error', + code: 'bootstrap_stalled', + label: 'WorkerA - failed to start', + detail: 'hard failure reason', + observedAt: NOW, + }, + { + id: 'WorkerB:permission_pending', + memberName: 'WorkerB', + severity: 'warning', + code: 'permission_pending', + label: 'WorkerB - awaiting permission', + detail: 'waiting for user approval', + observedAt: NOW, + }, + { + id: 'WorkerC:bootstrap_stalled', + memberName: 'WorkerC', + severity: 'warning', + code: 'bootstrap_stalled', + label: 'WorkerC - bootstrap stalled', + detail: 'bootstrap deadline exceeded', + observedAt: NOW, + }, + { + id: 'WorkerD:process_table_unavailable', + memberName: 'WorkerD', + severity: 'warning', + code: 'process_table_unavailable', + label: 'WorkerD - process table unavailable', + detail: 'process table temporarily unavailable', + observedAt: NOW, + }, + { + id: 'WorkerE:tmux_shell_only', + memberName: 'WorkerE', + severity: 'warning', + code: 'tmux_shell_only', + label: 'WorkerE - shell only', + detail: 'tmux pane only', + observedAt: NOW, + }, + { + id: 'WorkerF:runtime_process_candidate', + memberName: 'WorkerF', + severity: 'warning', + code: 'runtime_process_candidate', + label: 'WorkerF - bootstrap unconfirmed', + detail: 'process found but bootstrap missing', + observedAt: NOW, + }, + { + id: 'WorkerG:runtime_process_detected', + memberName: 'WorkerG', + severity: 'info', + code: 'runtime_process_detected', + label: 'WorkerG - waiting for bootstrap', + detail: 'process found', + observedAt: NOW, + }, + { + id: 'WorkerH:runtime_not_found', + memberName: 'WorkerH', + severity: 'warning', + code: 'runtime_not_found', + label: 'WorkerH - waiting for runtime', + detail: 'registered only', + observedAt: NOW, + }, + { + id: 'WorkerI:runtime_not_found', + memberName: 'WorkerI', + severity: 'warning', + code: 'runtime_not_found', + label: 'WorkerI - waiting for runtime', + detail: 'stale metadata', + observedAt: NOW, + }, + { + id: 'WorkerJ:runtime_not_found', + memberName: 'WorkerJ', + severity: 'warning', + code: 'runtime_not_found', + label: 'WorkerJ - waiting for runtime', + detail: 'no runtime', + observedAt: NOW, + }, + { + id: 'WorkerK:spawn_accepted', + memberName: 'WorkerK', + severity: 'info', + code: 'spawn_accepted', + label: 'WorkerK - spawn accepted', + detail: 'spawn accepted', + observedAt: NOW, + }, + ]); + }); + + it('uses failed launch error when hard failure reason is absent', () => { + expect( + buildLaunchDiagnosticsFromRun( + buildRun([['Worker', { launchState: 'failed_to_start', error: 'spawn failed' }]]), + { nowIso } + )?.[0] + ).toMatchObject({ + id: 'Worker:bootstrap_stalled', + detail: 'spawn failed', + }); + }); + + it('recognizes process table unavailable diagnostics case-insensitively', () => { + expect(mentionsProcessTableUnavailable('Process table is currently unavailable')).toBe(true); + expect(mentionsProcessTableUnavailable('process runtime unavailable')).toBe(false); + expect(mentionsProcessTableUnavailable(undefined)).toBe(false); + }); + + it('builds workspace trust launch diagnostics for blocked, soft-failed, and completed preflight results', () => { + expect( + buildWorkspaceTrustPreflightLaunchDiagnostic( + workspaceTrustExecution({ + status: 'blocked', + errorCode: 'workspace_trust_required', + errorMessage: ' trust must be accepted ', + evidence: ['fallback evidence'], + }), + { nowIso } + ) + ).toEqual({ + id: 'workspace-trust:preflight', + severity: 'error', + code: 'workspace_trust_preflight', + label: 'Workspace trust preflight blocked launch', + detail: 'trust must be accepted', + observedAt: NOW, + }); + + expect( + buildWorkspaceTrustPreflightLaunchDiagnostic( + workspaceTrustExecution({ + status: 'soft_failed', + errorCode: 'workspace_trust_probe_failed', + evidence: ['fallback evidence'], + }), + { nowIso } + ) + ).toMatchObject({ + severity: 'warning', + label: 'Workspace trust preflight could not verify trust', + detail: 'workspace_trust_probe_failed', + }); + + expect( + buildWorkspaceTrustPreflightLaunchDiagnostic( + workspaceTrustExecution({ + status: 'ok', + evidence: [' ', ' trusted from state probe '], + }), + { nowIso } + ) + ).toMatchObject({ + severity: 'info', + label: 'Workspace trust preflight completed', + detail: 'trusted from state probe', + }); + }); + + it('omits cancelled workspace trust launch diagnostics', () => { + expect( + buildWorkspaceTrustPreflightLaunchDiagnostic( + workspaceTrustExecution({ status: 'cancelled' }), + { nowIso } + ) + ).toBeNull(); + }); + + it('merges launch diagnostic items by id without mutating existing diagnostics', () => { + const existing: TeamLaunchDiagnosticItem[] = [ + { + id: 'old', + severity: 'info', + code: 'spawn_accepted', + label: 'Old', + observedAt: NOW, + }, + { + id: 'same', + severity: 'warning', + code: 'runtime_not_found', + label: 'Previous', + observedAt: NOW, + }, + ]; + const replacement: TeamLaunchDiagnosticItem = { + id: 'same', + severity: 'error', + code: 'bootstrap_stalled', + label: 'Replacement', + observedAt: NOW, + }; + + expect(mergeLaunchDiagnosticItem(existing, replacement)).toEqual([existing[0], replacement]); + expect(existing[1].label).toBe('Previous'); + expect(mergeLaunchDiagnosticItem(undefined, replacement)).toEqual([replacement]); + }); +}); diff --git a/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts b/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts new file mode 100644 index 00000000..a98a03f8 --- /dev/null +++ b/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts @@ -0,0 +1,113 @@ +import { + deriveMemberLaunchState, + isAutoClearableLaunchFailureReason, + isBootstrapCheckInTimeoutFailureReason, + isBootstrapInstructionPromptFailureReason, + isBootstrapMcpResourceReadFailureReason, + isConfigRegistrationFailureReason, + isLaunchCleanupBootstrapIncompleteFailureReason, + isLaunchGraceWindowFailureReason, + isNeverSpawnedDuringLaunchReason, + isOpenCodeBridgeLaunchFailureReason, + isProcessTableUnavailableFailureReason, + isRegisteredRuntimeMetadataFailureReason, + stripProcessTableUnavailableDiagnosticSuffix, +} from '@main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy'; +import { describe, expect, it } from 'vitest'; + +describe('TeamProvisioningLaunchFailurePolicy', () => { + it('recognizes exact launch failure reasons that are safe to auto-clear', () => { + expect(isNeverSpawnedDuringLaunchReason(' Teammate was never spawned during launch. ')).toBe( + true + ); + expect( + isLaunchGraceWindowFailureReason('Teammate did not join within the launch grace window.') + ).toBe(true); + expect( + isConfigRegistrationFailureReason( + 'Teammate was not registered in config.json during launch. Persistent spawn failed.' + ) + ).toBe(true); + expect(isOpenCodeBridgeLaunchFailureReason('OpenCode bridge reported member launch failure')).toBe( + true + ); + expect( + isRegisteredRuntimeMetadataFailureReason('registered runtime metadata without live process') + ).toBe(true); + }); + + it('recognizes bootstrap-specific failure reasons without accepting unrelated text', () => { + expect( + isBootstrapMcpResourceReadFailureReason( + 'resources/read failed for member_briefing: MCP error method not found' + ) + ).toBe(true); + expect(isBootstrapMcpResourceReadFailureReason('resources/read failed for other resource')).toBe( + false + ); + expect( + isBootstrapCheckInTimeoutFailureReason( + 'Teammate was registered but did not bootstrap-confirm before timeout.' + ) + ).toBe(true); + expect( + isBootstrapInstructionPromptFailureReason( + 'You are bootstrapping into team atlas. Your first action is to call the MCP tool member_briefing.' + ) + ).toBe(true); + expect( + isLaunchCleanupBootstrapIncompleteFailureReason( + 'Launch ended before teammate bootstrap completed. Runtime process was alive after bootstrap failure' + ) + ).toBe(true); + }); + + it('handles process-table unavailable reasons and suffixes conservatively', () => { + expect(isProcessTableUnavailableFailureReason('process table unavailable')).toBe(true); + expect( + isProcessTableUnavailableFailureReason( + 'runtime pid could not be verified because process table is unavailable' + ) + ).toBe(true); + expect(isProcessTableUnavailableFailureReason('runtime failed; process table unavailable')).toBe( + false + ); + expect( + stripProcessTableUnavailableDiagnosticSuffix( + 'Teammate did not join within the launch grace window.; process table unavailable' + ) + ).toBe('Teammate did not join within the launch grace window.'); + }); + + it('keeps auto-clear policy narrow but accepts known recoverable suffixes', () => { + expect( + isAutoClearableLaunchFailureReason('Teammate was never spawned during launch.') + ).toBe(true); + expect(isAutoClearableLaunchFailureReason('process table is unavailable')).toBe(true); + expect( + isAutoClearableLaunchFailureReason( + 'Teammate did not join within the launch grace window.; process table unavailable' + ) + ).toBe(true); + expect(isAutoClearableLaunchFailureReason('model not found')).toBe(false); + expect(isAutoClearableLaunchFailureReason(undefined)).toBe(false); + }); + + it('derives member launch state by the existing precedence order', () => { + expect(deriveMemberLaunchState({ skippedForLaunch: true, hardFailure: true })).toBe( + 'skipped_for_launch' + ); + expect(deriveMemberLaunchState({ hardFailure: true, bootstrapConfirmed: true })).toBe( + 'failed_to_start' + ); + expect(deriveMemberLaunchState({ bootstrapConfirmed: true })).toBe('confirmed_alive'); + expect(deriveMemberLaunchState({ pendingPermissionRequestIds: ['req-1'] })).toBe( + 'runtime_pending_permission' + ); + expect(deriveMemberLaunchState({ runtimeAlive: true })).toBe('runtime_pending_bootstrap'); + expect(deriveMemberLaunchState({ agentToolAccepted: true })).toBe( + 'runtime_pending_bootstrap' + ); + expect(deriveMemberLaunchState({})).toBe('starting'); + }); +}); diff --git a/test/main/services/team/TeamProvisioningMemberIdentity.test.ts b/test/main/services/team/TeamProvisioningMemberIdentity.test.ts new file mode 100644 index 00000000..a03b0b43 --- /dev/null +++ b/test/main/services/team/TeamProvisioningMemberIdentity.test.ts @@ -0,0 +1,54 @@ +import { + isOpenCodeOverlayMemberRemoved, + matchesExactTeamMemberName, + matchesMemberNameOrBase, + matchesObservedMemberNameForExpected, + matchesTeamMemberIdentity, + namesMatchCaseInsensitive, +} from '@main/services/team/provisioning/TeamProvisioningMemberIdentity'; +import { describe, expect, it } from 'vitest'; + +describe('TeamProvisioningMemberIdentity', () => { + it('matches a member name against its auto-suffixed variants', () => { + expect(matchesMemberNameOrBase('Builder', 'Builder')).toBe(true); + expect(matchesMemberNameOrBase('Builder-2', 'Builder')).toBe(true); + expect(matchesMemberNameOrBase('Builder-10', 'Builder')).toBe(true); + expect(matchesMemberNameOrBase('Builder-1', 'Builder')).toBe(false); + expect(matchesMemberNameOrBase('Builder 2', 'Builder')).toBe(false); + expect(matchesMemberNameOrBase('Builder-2', 'builder')).toBe(false); + expect(matchesMemberNameOrBase('Reviewer-2', 'Builder')).toBe(false); + }); + + it('matches team member identity in either direction', () => { + expect(matchesTeamMemberIdentity('Builder-2', 'Builder')).toBe(true); + expect(matchesTeamMemberIdentity('Builder', 'Builder-2')).toBe(true); + expect(matchesTeamMemberIdentity('Builder-2', 'Builder-3')).toBe(false); + }); + + it('keeps observed-name matching one-directional', () => { + expect(matchesObservedMemberNameForExpected('Builder-2', 'Builder')).toBe(true); + expect(matchesObservedMemberNameForExpected('Builder', 'Builder-2')).toBe(false); + }); + + it('matches exact team member names case-insensitively after trimming', () => { + expect(matchesExactTeamMemberName(' Builder ', 'builder')).toBe(true); + expect(matchesExactTeamMemberName('', 'builder')).toBe(false); + expect(matchesExactTeamMemberName('Builder-2', 'Builder')).toBe(false); + }); + + it('detects removed OpenCode overlay members case-insensitively', () => { + expect(namesMatchCaseInsensitive(' Builder ', 'builder')).toBe(true); + expect(namesMatchCaseInsensitive('Builder-2', 'Builder')).toBe(false); + expect( + isOpenCodeOverlayMemberRemoved( + [ + { name: 'Reviewer', removedAt: undefined }, + { name: ' Builder ', removedAt: 123 }, + ], + 'builder' + ) + ).toBe(true); + expect(isOpenCodeOverlayMemberRemoved([{ name: 'Builder' }], 'builder')).toBe(false); + expect(isOpenCodeOverlayMemberRemoved([{ removedAt: 123 }], 'builder')).toBe(false); + }); +}); diff --git a/test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts b/test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts new file mode 100644 index 00000000..f425ba1c --- /dev/null +++ b/test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts @@ -0,0 +1,190 @@ +import { + buildRestartDuplicateUnconfirmedReason, + buildRestartGraceTimeoutReason, + buildRestartStillRunningReason, + createInitialMemberSpawnStatusEntry, + deriveTaskActivityPauseAt, + deriveTaskActivityResumeAt, + MEMBER_LAUNCH_GRACE_MS, + parseOptionalIsoMs, + shouldWarnOnMissingRegisteredMember, + shouldWarnOnUnreadableMemberAuditConfig, + summarizeMemberSpawnStatusRecord, +} from '@main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy'; +import { describe, expect, it, vi } from 'vitest'; + +import type { MemberSpawnStatusEntry } from '@shared/types'; + +function makeStatus(overrides: Partial = {}): MemberSpawnStatusEntry { + return { + status: 'offline', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('TeamProvisioningMemberSpawnStatusPolicy', () => { + it('warns about unreadable audit config only after throttle and launch grace windows', () => { + const acceptedAt = '2026-01-01T00:00:00.000Z'; + const nowMs = Date.parse(acceptedAt) + MEMBER_LAUNCH_GRACE_MS; + const memberSpawnStatuses = new Map([ + ['Builder', { agentToolAccepted: true, firstSpawnAcceptedAt: acceptedAt }], + ]); + + expect( + shouldWarnOnUnreadableMemberAuditConfig({ + nowMs, + lastWarnAt: nowMs - 9_999, + expectedMembers: ['Builder'], + memberSpawnStatuses, + }) + ).toBe(false); + expect( + shouldWarnOnUnreadableMemberAuditConfig({ + nowMs, + lastWarnAt: 0, + expectedMembers: ['Builder'], + memberSpawnStatuses, + }) + ).toBe(true); + expect( + shouldWarnOnUnreadableMemberAuditConfig({ + nowMs, + lastWarnAt: 0, + expectedMembers: ['Reviewer'], + memberSpawnStatuses, + }) + ).toBe(false); + }); + + it('warns about missing registered members only after grace expiry and throttle', () => { + expect( + shouldWarnOnMissingRegisteredMember({ + nowMs: 20_000, + lastWarnAt: 0, + graceExpired: false, + }) + ).toBe(false); + expect( + shouldWarnOnMissingRegisteredMember({ + nowMs: 20_000, + lastWarnAt: 15_000, + graceExpired: true, + }) + ).toBe(false); + expect( + shouldWarnOnMissingRegisteredMember({ + nowMs: 20_000, + lastWarnAt: 0, + graceExpired: true, + }) + ).toBe(true); + }); + + it('derives bounded task activity pause and resume timestamps', () => { + expect(parseOptionalIsoMs(undefined)).toBe(0); + expect(parseOptionalIsoMs('not-a-date')).toBe(0); + expect(parseOptionalIsoMs('2026-01-01T00:00:00.000Z')).toBe( + Date.parse('2026-01-01T00:00:00.000Z') + ); + expect( + deriveTaskActivityPauseAt( + makeStatus({ lastHeartbeatAt: '2026-01-01T00:00:00.000Z' }), + '2026-01-01T00:00:10.000Z' + ) + ).toBe('2026-01-01T00:00:05.000Z'); + expect( + deriveTaskActivityPauseAt( + makeStatus({ lastHeartbeatAt: 'not-a-date' }), + '2026-01-01T00:00:10.000Z' + ) + ).toBe('2026-01-01T00:00:05.000Z'); + expect( + deriveTaskActivityPauseAt( + makeStatus({ updatedAt: 'not-a-date' }), + '2026-01-01T00:00:10.000Z' + ) + ).toBe('2026-01-01T00:00:10.000Z'); + expect( + deriveTaskActivityResumeAt( + makeStatus({ updatedAt: '2026-01-01T00:00:05.000Z' }), + '2026-01-01T00:00:06.000Z', + '2026-01-01T00:00:10.000Z' + ) + ).toBe('2026-01-01T00:00:06.000Z'); + expect( + deriveTaskActivityResumeAt( + makeStatus({ updatedAt: '2026-01-01T00:00:05.000Z' }), + '2026-01-01T00:00:04.000Z', + '2026-01-01T00:00:10.000Z' + ) + ).toBe('2026-01-01T00:00:10.000Z'); + }); + + it('creates initial member spawn statuses with the current timestamp', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-02T03:04:05.000Z')); + try { + expect(createInitialMemberSpawnStatusEntry()).toEqual({ + status: 'offline', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + updatedAt: '2026-01-02T03:04:05.000Z', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('summarizes member spawn statuses across launch states and liveness kinds', () => { + expect( + summarizeMemberSpawnStatusRecord(['Confirmed', 'Missing'], { + Confirmed: makeStatus({ launchState: 'confirmed_alive' }), + Skipped: makeStatus({ launchState: 'skipped_for_launch' }), + Failed: makeStatus({ launchState: 'failed_to_start' }), + Permission: makeStatus({ + launchState: 'runtime_pending_permission', + runtimeAlive: true, + }), + Shell: makeStatus({ livenessKind: 'shell_only' }), + Runtime: makeStatus({ livenessKind: 'runtime_process' }), + Candidate: makeStatus({ livenessKind: 'runtime_process_candidate' }), + MissingRuntime: makeStatus({ livenessKind: 'registered_only' }), + }) + ).toEqual({ + confirmedCount: 1, + pendingCount: 6, + failedCount: 1, + skippedCount: 1, + runtimeAlivePendingCount: 1, + shellOnlyPendingCount: 1, + runtimeProcessPendingCount: 1, + runtimeCandidatePendingCount: 1, + noRuntimePendingCount: 1, + permissionPendingCount: 1, + }); + }); + + it('builds restart status reasons without changing message text', () => { + expect(buildRestartStillRunningReason('Builder')).toContain( + 'previous runtime still appears to be active' + ); + expect(buildRestartDuplicateUnconfirmedReason('Builder')).toContain( + 'duplicate_skipped without a reason' + ); + expect(buildRestartDuplicateUnconfirmedReason('Builder', 'still running')).toContain( + 'unrecognized reason "still running"' + ); + expect(buildRestartGraceTimeoutReason('Builder')).toBe( + 'Teammate "Builder" did not rejoin within the restart grace window.' + ); + }); +}); diff --git a/test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts new file mode 100644 index 00000000..abb3a23d --- /dev/null +++ b/test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts @@ -0,0 +1,129 @@ +import { + boundOpenCodeAppManagedBriefingText, + filterStaleOpenCodeOverlayDiagnostics, + hasRealOpenCodeFailureDiagnostic, + hasRealOpenCodeLaunchDiagnostic, + hasStaleOpenCodeDiagnostics, + isFileLockTimeoutError, + isGenericOpenCodePersistedFailureReason, + isPersistedOpenCodeSecondaryLaneMember, + normalizeOpenCodePersistedFailureReason, + promoteOpenCodePersistedFailureReasonsFromDiagnostics, + selectOpenCodePersistedFailureReasonFromDiagnostics, +} from '@main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy'; +import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { describe, expect, it, vi } from 'vitest'; + +import type { PersistedTeamLaunchMemberState } from '@shared/types'; + +function makeMember( + overrides: Partial = {} +): PersistedTeamLaunchMemberState { + return { + name: 'Builder', + providerId: 'opencode', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + laneId: 'opencode-secondary', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + lastEvaluatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('TeamProvisioningOpenCodeDiagnosticsPolicy', () => { + it('recognizes only persisted OpenCode secondary lane members', () => { + expect(isPersistedOpenCodeSecondaryLaneMember(makeMember())).toBe(true); + expect(isPersistedOpenCodeSecondaryLaneMember(makeMember({ laneId: ' ' }))).toBe(false); + expect(isPersistedOpenCodeSecondaryLaneMember(makeMember({ laneKind: 'primary' }))).toBe(false); + expect(isPersistedOpenCodeSecondaryLaneMember(makeMember({ providerId: undefined }))).toBe( + false + ); + }); + + it('keeps stale OpenCode diagnostics separate from real launch failures', () => { + expect(hasStaleOpenCodeDiagnostics(['No lane runtime evidence was committed'])).toBe(true); + expect(hasStaleOpenCodeDiagnostics(['OpenCode bridge reported member launch failure'])).toBe( + true + ); + expect(hasStaleOpenCodeDiagnostics(['model not found in live OpenCode catalog'])).toBe(false); + expect(hasRealOpenCodeFailureDiagnostic('provider unavailable: quota exceeded')).toBe(true); + expect( + hasRealOpenCodeLaunchDiagnostic( + makeMember({ runtimeDiagnostic: 'OpenCode bridge reported member launch failure' }) + ) + ).toBe(false); + expect(hasRealOpenCodeLaunchDiagnostic(makeMember({ hardFailureReason: 'model not found' }))).toBe( + true + ); + }); + + it('redacts secrets and bounds app-managed briefing text', () => { + const normalized = normalizeOpenCodePersistedFailureReason( + ' failed --api-key sk-abcdefghijklmnopqrstuvwxyz Bearer ABC123._+/=- ' + ); + expect(normalized).toBe('failed --api-key [redacted] Bearer [redacted]'); + expect(isGenericOpenCodePersistedFailureReason('OpenCode bridge reported member launch failure')).toBe( + true + ); + + const longBriefing = `${'x'.repeat(12_005)} --token secret-token`; + const bounded = boundOpenCodeAppManagedBriefingText(longBriefing); + expect(bounded.length).toBeGreaterThan(12_000); + expect(bounded.length).toBeLessThanOrEqual(12_040); + expect(bounded.endsWith('\n[truncated app-managed briefing]')).toBe(true); + expect(bounded).not.toContain('secret-token'); + }); + + it('promotes generic persisted failure reasons from specific diagnostics', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-03T04:05:06.000Z')); + try { + const generic = makeMember({ + diagnostics: [ + 'OpenCode secondary lane timing: 100ms', + 'model not found in live OpenCode catalog', + ], + }); + expect(selectOpenCodePersistedFailureReasonFromDiagnostics(generic)).toBe( + 'model not found in live OpenCode catalog' + ); + + const snapshot = createPersistedLaunchSnapshot({ + teamName: 'demo', + expectedMembers: ['Builder'], + launchPhase: 'finished', + members: { Builder: generic }, + updatedAt: '2026-01-01T00:00:00.000Z', + }); + const promoted = promoteOpenCodePersistedFailureReasonsFromDiagnostics(snapshot); + expect(promoted?.members.Builder.hardFailureReason).toBe( + 'model not found in live OpenCode catalog' + ); + expect(promoted?.members.Builder.runtimeDiagnostic).toBe( + 'model not found in live OpenCode catalog' + ); + expect(promoted?.updatedAt).toBe('2026-02-03T04:05:06.000Z'); + } finally { + vi.useRealTimers(); + } + }); + + it('filters stale overlay diagnostics and recognizes file lock timeouts', () => { + expect( + filterStaleOpenCodeOverlayDiagnostics([ + 'No runtime evidence was committed', + 'model not found', + ]) + ).toEqual(['model not found']); + expect(isFileLockTimeoutError(new Error('File lock timeout while reading manifest'))).toBe( + true + ); + expect(isFileLockTimeoutError('other failure')).toBe(false); + }); +}); diff --git a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts new file mode 100644 index 00000000..a226818b --- /dev/null +++ b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts @@ -0,0 +1,488 @@ +import { + appendDiagnosticOnce, + buildOpenCodeSecondaryLaneTimingDiagnostic, + buildOpenCodeUncommittedBootstrapDiagnostic, + collectOpenCodeSecondaryLaneFailureDiagnostics, + collectRuntimeLaunchFailureDiagnostics, + createUnexpectedMixedSecondaryLaneFailureResult, + downgradeUncommittedOpenCodeBootstrapEvidence, + formatOpenCodeLaneTimingMs, + getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted, + hasMaterializedOpenCodeRuntimeForBootstrap, + hasOpenCodeRuntimeEntryHandle, + hasOpenCodeRuntimeHandle, + hasOpenCodeRuntimeLivenessMarker, + hasRecoverableOpenCodeBootstrapDiagnostic, + isBootstrapMemberEvidenceCurrentForMember, + isDefinitiveOpenCodePreLaunchFailure, + isExplicitLegacyOpenCodeBootstrap, + isMaterializedOpenCodeSessionId, + isRecoverableOpenCodeBootstrapPendingLaunchResult, + isRecoverableOpenCodeRuntimeEvidence, + isRecoverablePersistedOpenCodeRuntimeCandidate, + isRecoverablePersistedOpenCodeTerminalRuntimeCandidate, + MEMBER_BOOTSTRAP_STALL_MS, + normalizeIsoTimestamp, + normalizeRecoverableOpenCodeBootstrapPendingLaunchResult, + OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC, + OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC, + OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC, + promoteCommittedOpenCodeAppManagedBootstrapEvidence, + resolveOpenCodeBootstrapAcceptedAt, + selectOpenCodeSecondaryBootstrapStallDiagnostic, + shouldMarkPersistedOpenCodeBootstrapStalled, + summarizeRuntimeLaunchResultMembers, +} from '@main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy'; +import { describe, expect, it } from 'vitest'; + +import type { + TeamRuntimeLaunchResult, + TeamRuntimeMemberLaunchEvidence, +} from '@main/services/team/runtime/TeamRuntimeAdapter'; +import type { PersistedTeamLaunchMemberState } from '@shared/types'; + +const acceptedAt = '2026-01-01T00:00:00.000Z'; +const stalledAtMs = Date.parse(acceptedAt) + MEMBER_BOOTSTRAP_STALL_MS + 1; + +function makePersisted( + overrides: Partial = {} +): PersistedTeamLaunchMemberState { + return { + name: 'Builder', + providerId: 'opencode', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + laneId: 'opencode-secondary', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + firstSpawnAcceptedAt: acceptedAt, + lastEvaluatedAt: acceptedAt, + ...overrides, + }; +} + +function makeEvidence( + overrides: Partial = {} +): TeamRuntimeMemberLaunchEvidence { + return { + memberName: 'Builder', + providerId: 'opencode', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + diagnostics: [], + ...overrides, + }; +} + +function makeLaunchResult( + member: TeamRuntimeMemberLaunchEvidence = makeEvidence(), + overrides: Partial = {} +): TeamRuntimeLaunchResult { + return { + runId: 'run-1', + teamName: 'demo', + launchPhase: 'active', + teamLaunchState: 'partial_pending', + members: { [member.memberName]: member }, + warnings: [], + diagnostics: [], + ...overrides, + }; +} + +describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => { + it('formats timing diagnostics and appends diagnostics without duplicates', () => { + expect(formatOpenCodeLaneTimingMs(12.6)).toBe('13ms'); + expect(formatOpenCodeLaneTimingMs(-4.4)).toBe('0ms'); + expect(formatOpenCodeLaneTimingMs(Number.NaN)).toBe('n/a'); + expect(appendDiagnosticOnce(['existing'], 'new')).toEqual(['existing', 'new']); + expect(appendDiagnosticOnce(['existing'], 'existing')).toEqual(['existing']); + expect(appendDiagnosticOnce(['existing'], null)).toEqual(['existing']); + expect( + buildOpenCodeSecondaryLaneTimingDiagnostic({ + member: { name: 'Builder' }, + queuedAtMs: 10, + launchStartedAtMs: 40, + launchFinishedAtMs: 145, + }) + ).toBe( + 'OpenCode secondary lane timing: member=Builder queueWaitMs=30ms launchMs=105ms totalMs=135ms' + ); + expect( + buildOpenCodeSecondaryLaneTimingDiagnostic({ + member: { name: 'Builder' }, + queuedAtMs: 10, + launchStartedAtMs: 40, + }) + ).toBeNull(); + }); + + it('recognizes OpenCode runtime handles without accepting empty or failed sessions', () => { + expect(hasOpenCodeRuntimeHandle({ runtimePid: 12 })).toBe(true); + expect(hasOpenCodeRuntimeHandle({ runtimeSessionId: ' session-1 ' })).toBe(true); + expect(hasOpenCodeRuntimeHandle({ sessionId: 'session-2' })).toBe(true); + expect(hasOpenCodeRuntimeHandle({ runtimePid: 0, sessionId: ' ' })).toBe(false); + expect(hasOpenCodeRuntimeLivenessMarker({ livenessKind: 'runtime_process_candidate' })).toBe( + true + ); + expect(hasOpenCodeRuntimeLivenessMarker({ livenessKind: 'registered_only' })).toBe(false); + }); + + it('collects launch diagnostics and detects definitive pre-launch failures', () => { + const result = makeLaunchResult( + makeEvidence({ + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'binary missing', + diagnostics: ['member diagnostic'], + }), + { diagnostics: ['result diagnostic'] } + ); + expect(collectRuntimeLaunchFailureDiagnostics(result, 'Builder')).toEqual([ + 'member diagnostic', + 'binary missing', + 'result diagnostic', + ]); + expect(collectOpenCodeSecondaryLaneFailureDiagnostics(result, 'Builder', ['prefix'])).toEqual([ + 'prefix', + 'member diagnostic', + 'binary missing', + 'result diagnostic', + ]); + expect( + collectOpenCodeSecondaryLaneFailureDiagnostics(makeLaunchResult(), 'Missing', []) + ).toEqual(['OpenCode bridge reported member launch failure']); + expect(isDefinitiveOpenCodePreLaunchFailure(result, 'Builder')).toBe(true); + expect( + isDefinitiveOpenCodePreLaunchFailure( + makeLaunchResult( + makeEvidence({ + launchState: 'failed_to_start', + hardFailure: true, + diagnostics: ['outcome must be reconciled before retry'], + }) + ), + 'Builder' + ) + ).toBe(false); + expect( + isDefinitiveOpenCodePreLaunchFailure( + makeLaunchResult( + makeEvidence({ + launchState: 'failed_to_start', + hardFailure: true, + sessionId: 'runtime-session', + }) + ), + 'Builder' + ) + ).toBe(false); + }); + + it('normalizes recoverable bootstrap-pending launch results', () => { + const result = makeLaunchResult( + makeEvidence({ + sessionId: 'runtime-session', + diagnostics: ['member_briefing not connected'], + }), + { diagnostics: ['result diagnostic'] } + ); + expect(isMaterializedOpenCodeSessionId('runtime-session')).toBe(true); + expect(isMaterializedOpenCodeSessionId('failed:runtime-session')).toBe(false); + expect(hasMaterializedOpenCodeRuntimeForBootstrap(result.members.Builder)).toBe(true); + expect(isRecoverableOpenCodeBootstrapPendingLaunchResult(result, 'Builder')).toBe(true); + + const normalized = normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(result, 'Builder', [ + 'extra diagnostic', + ]); + expect(normalized.members.Builder.launchState).toBe('runtime_pending_bootstrap'); + expect(normalized.members.Builder.runtimeAlive).toBe(true); + expect(normalized.members.Builder.hardFailure).toBe(false); + expect(normalized.members.Builder.diagnostics).toEqual([ + 'member_briefing not connected', + OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC, + OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC, + 'extra diagnostic', + ]); + expect(normalized.diagnostics).toContain('result diagnostic'); + expect(normalized.diagnostics).toContain(OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC); + expect(normalized.teamLaunchState).toBe('partial_pending'); + expect(normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(result, 'Missing', [])).toBe( + result + ); + }); + + it('summarizes launch result members and transforms bootstrap evidence', () => { + expect( + summarizeRuntimeLaunchResultMembers({ + Builder: makeEvidence({ launchState: 'confirmed_alive' }), + Reviewer: makeEvidence({ memberName: 'Reviewer', launchState: 'confirmed_alive' }), + }) + ).toBe('clean_success'); + expect( + summarizeRuntimeLaunchResultMembers({ + Builder: makeEvidence({ launchState: 'confirmed_alive' }), + Reviewer: makeEvidence({ + memberName: 'Reviewer', + launchState: 'failed_to_start', + hardFailure: true, + }), + }) + ).toBe('partial_failure'); + expect(summarizeRuntimeLaunchResultMembers({})).toBe('partial_pending'); + + expect( + buildOpenCodeUncommittedBootstrapDiagnostic({ + manifestEntryCount: 2, + manifestUpdatedAt: '2026-01-01T00:00:00.000Z', + fileNames: ['lane-a.json', 'lane-b.json'], + }) + ).toEqual([ + 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.', + 'OpenCode lane manifest entries: 2', + 'OpenCode lane manifest updated at: 2026-01-01T00:00:00.000Z', + 'OpenCode lane files: lane-a.json, lane-b.json', + ]); + + const downgraded = downgradeUncommittedOpenCodeBootstrapEvidence( + makeEvidence({ + sessionId: 'runtime-session', + livenessKind: 'confirmed_bootstrap', + diagnostics: ['existing'], + }), + ['new diagnostic'] + ); + expect(downgraded.launchState).toBe('runtime_pending_bootstrap'); + expect(downgraded.livenessKind).toBe('runtime_process_candidate'); + expect(downgraded.diagnostics).toEqual(['existing', 'new diagnostic']); + + const promoted = promoteCommittedOpenCodeAppManagedBootstrapEvidence( + makeEvidence({ diagnostics: ['existing'] }) + ); + expect(promoted.launchState).toBe('confirmed_alive'); + expect(promoted.bootstrapConfirmed).toBe(true); + expect(promoted.livenessKind).toBe('confirmed_bootstrap'); + expect(promoted.diagnostics).toEqual([ + 'existing', + 'OpenCode app-managed bootstrap evidence committed and read back.', + ]); + }); + + it('builds unexpected mixed secondary lane failure results', () => { + const result = createUnexpectedMixedSecondaryLaneFailureResult({ + runId: 'run-1', + teamName: 'demo', + memberName: 'Builder', + message: 'launch failed', + }); + expect(result.launchPhase).toBe('finished'); + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.members.Builder).toMatchObject({ + providerId: 'opencode', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'launch failed', + diagnostics: ['launch failed'], + }); + expect(result.diagnostics).toEqual(['launch failed']); + }); + + it('recognizes runtime entry handles from pid, runtime session, or liveness', () => { + expect(hasOpenCodeRuntimeEntryHandle({ pid: 7 })).toBe(true); + expect(hasOpenCodeRuntimeEntryHandle({ runtimePid: 8 })).toBe(true); + expect(hasOpenCodeRuntimeEntryHandle({ runtimeSessionId: 'runtime-session' })).toBe(true); + expect(hasOpenCodeRuntimeEntryHandle({ livenessKind: 'permission_blocked' })).toBe(true); + expect(hasOpenCodeRuntimeEntryHandle({ pid: 0, runtimeSessionId: ' ' })).toBe(false); + }); + + it('keeps recoverable bootstrap diagnostics separate from real failures', () => { + expect(hasRecoverableOpenCodeBootstrapDiagnostic(['member_briefing not connected'])).toBe(true); + expect(hasRecoverableOpenCodeBootstrapDiagnostic(['runtime_bootstrap_checkin pending'])).toBe( + true + ); + expect(hasRecoverableOpenCodeBootstrapDiagnostic(['provider unavailable: quota exceeded'])).toBe( + false + ); + expect(hasRecoverableOpenCodeBootstrapDiagnostic([])).toBe(false); + }); + + it('classifies recoverable persisted OpenCode runtime candidates', () => { + expect( + isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ runtimeSessionId: 'rt-1' })) + ).toBe(true); + expect( + isRecoverablePersistedOpenCodeRuntimeCandidate( + makePersisted({ pendingPermissionRequestIds: ['perm-1'] }) + ) + ).toBe(true); + expect( + isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ agentToolAccepted: false })) + ).toBe(false); + expect( + isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ skippedForLaunch: true })) + ).toBe(false); + expect( + isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ laneKind: 'primary' })) + ).toBe(false); + }); + + it('selects the earliest accepted-at timestamp from first spawn and diagnostics', () => { + expect(normalizeIsoTimestamp('2026-01-01T00:00:00Z')).toBe('2026-01-01T00:00:00.000Z'); + expect(normalizeIsoTimestamp('not a date')).toBeNull(); + expect( + resolveOpenCodeBootstrapAcceptedAt( + makePersisted({ + firstSpawnAcceptedAt: '2026-01-01T00:10:00.000Z', + diagnostics: ['member_session_recorded at 2026-01-01T00:05:00.000Z'], + }) + ) + ).toBe('2026-01-01T00:05:00.000Z'); + expect( + resolveOpenCodeBootstrapAcceptedAt( + makePersisted({ firstSpawnAcceptedAt: 'not a date', diagnostics: ['noise'] }) + ) + ).toBeUndefined(); + }); + + it('builds legacy and app-managed bootstrap stall diagnostics', () => { + expect(isExplicitLegacyOpenCodeBootstrap({ bootstrapMode: 'model_tool_checkin' })).toBe(true); + expect(isExplicitLegacyOpenCodeBootstrap({ bootstrapMode: 'app_managed_context' })).toBe(false); + expect( + selectOpenCodeSecondaryBootstrapStallDiagnostic([ + 'OpenCode secondary lane timing: 100ms', + 'member_briefing delivery pending', + ]) + ).toBe('member_briefing delivery pending; runtime_bootstrap_checkin did not complete after 5 min.'); + expect(getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted(makePersisted())).toBe( + OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC + ); + expect( + getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted( + makePersisted({ + bootstrapMode: 'model_tool_checkin', + diagnostics: ['member_briefing delivery pending'], + }) + ) + ).toBe('member_briefing delivery pending; runtime_bootstrap_checkin did not complete after 5 min.'); + expect( + getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted( + makePersisted({ + bootstrapMode: 'model_tool_checkin', + runtimeDiagnostic: 'runtime_bootstrap_checkin timed out', + }) + ) + ).toBe('runtime_bootstrap_checkin timed out'); + }); + + it('marks persisted bootstrap as stalled only after threshold with recoverable evidence', () => { + expect( + shouldMarkPersistedOpenCodeBootstrapStalled( + makePersisted({ runtimeSessionId: 'runtime-session' }), + stalledAtMs + ) + ).toBe(true); + expect( + shouldMarkPersistedOpenCodeBootstrapStalled( + makePersisted({ diagnostics: ['member_briefing not connected'] }), + stalledAtMs + ) + ).toBe(true); + expect( + shouldMarkPersistedOpenCodeBootstrapStalled( + makePersisted({ runtimeSessionId: 'runtime-session' }), + Date.parse(acceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - 1 + ) + ).toBe(false); + expect( + shouldMarkPersistedOpenCodeBootstrapStalled( + makePersisted({ runtimeSessionId: 'runtime-session', hardFailureReason: 'model not found' }), + stalledAtMs + ) + ).toBe(false); + expect( + shouldMarkPersistedOpenCodeBootstrapStalled( + makePersisted({ + runtimeSessionId: 'runtime-session', + pendingPermissionRequestIds: ['perm-1'], + }), + stalledAtMs + ) + ).toBe(false); + }); + + it('recognizes recoverable terminal persisted candidates and runtime evidence', () => { + expect( + isRecoverablePersistedOpenCodeTerminalRuntimeCandidate( + makePersisted({ + launchState: 'failed_to_start', + hardFailure: true, + runtimeSessionId: 'runtime-session', + }) + ) + ).toBe(true); + expect( + isRecoverablePersistedOpenCodeTerminalRuntimeCandidate( + makePersisted({ launchState: 'failed_to_start', hardFailure: true }) + ) + ).toBe(false); + + const evidence: Partial = { + agentToolAccepted: true, + livenessKind: 'runtime_process', + }; + expect(isRecoverableOpenCodeRuntimeEvidence(evidence as TeamRuntimeMemberLaunchEvidence)).toBe( + true + ); + expect(isRecoverableOpenCodeRuntimeEvidence({ runtimeAlive: true } as TeamRuntimeMemberLaunchEvidence)).toBe( + true + ); + expect(isRecoverableOpenCodeRuntimeEvidence(undefined)).toBe(false); + }); + + it('checks bootstrap evidence recency against the current member spawn boundary', () => { + const current = { + firstSpawnAcceptedAt: '2026-01-01T00:01:00.000Z', + lastEvaluatedAt: '2026-01-01T00:01:10.000Z', + }; + expect( + isBootstrapMemberEvidenceCurrentForMember( + current, + { + firstSpawnAcceptedAt: '2026-01-01T00:01:01.000Z', + lastHeartbeatAt: '2026-01-01T00:01:02.000Z', + lastEvaluatedAt: '2026-01-01T00:01:03.000Z', + }, + 'acceptance' + ) + ).toBe(true); + expect( + isBootstrapMemberEvidenceCurrentForMember( + current, + { + firstSpawnAcceptedAt: '2026-01-01T00:00:30.000Z', + lastHeartbeatAt: '2026-01-01T00:00:40.000Z', + lastEvaluatedAt: '2026-01-01T00:00:50.000Z', + }, + 'confirmation' + ) + ).toBe(false); + expect( + isBootstrapMemberEvidenceCurrentForMember( + { firstSpawnAcceptedAt: undefined, lastEvaluatedAt: undefined }, + { + firstSpawnAcceptedAt: 'not-a-date', + lastHeartbeatAt: undefined, + lastRuntimeAliveAt: '2026-01-01T00:00:40.000Z', + lastEvaluatedAt: '2026-01-01T00:00:30.000Z', + }, + 'confirmation' + ) + ).toBe(true); + }); +}); diff --git a/test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts b/test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts new file mode 100644 index 00000000..7e4397c7 --- /dev/null +++ b/test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts @@ -0,0 +1,213 @@ +import { + buildRuntimeLaunchWarning, + getAnthropicFastModeDefault, + getConfiguredRuntimeBackend, + getPromptSizeSummary, + getTeamProviderLabel, + logRuntimeLaunchSnapshot, +} from '@main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GeminiRuntimeAuthState } from '@main/services/runtime/geminiRuntimeAuth'; +import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types'; + +vi.mock('@main/services/infrastructure/ConfigManager', () => ({ + ConfigManager: { + getInstance: vi.fn().mockReturnValue({ + getConfig: vi.fn().mockReturnValue({ + providerConnections: { + anthropic: { + fastModeDefault: true, + }, + }, + runtime: { + providerBackends: { + codex: 'codex-native', + gemini: 'cli-sdk', + }, + }, + }), + }), + }, +})); + +describe('TeamProvisioningRuntimeDiagnostics', () => { + it('keeps prompt size accounting stable for empty and multiline prompts', () => { + expect(getPromptSizeSummary('')).toEqual({ chars: 0, lines: 0 }); + expect(getPromptSizeSummary('alpha\r\nbeta\ngamma')).toEqual({ + chars: 'alpha\r\nbeta\ngamma'.length, + lines: 3, + }); + }); + + it('labels supported team providers without leaking raw ids into diagnostics', () => { + const labels = new Map([ + ['anthropic', 'Anthropic'], + ['codex', 'Codex'], + ['gemini', 'Gemini'], + ['opencode', 'OpenCode'], + ]); + + for (const [providerId, label] of labels) { + expect(getTeamProviderLabel(providerId)).toBe(label); + } + }); + + it('reads configured runtime defaults through a narrow diagnostics adapter', () => { + expect(getAnthropicFastModeDefault()).toBe(true); + expect(getConfiguredRuntimeBackend('anthropic')).toBeNull(); + expect(getConfiguredRuntimeBackend('opencode')).toBeNull(); + expect(getConfiguredRuntimeBackend('codex')).toBe('codex-native'); + expect(getConfiguredRuntimeBackend('gemini')).toBe('cli-sdk'); + }); + + it('builds Codex launch warnings with explicit backend, prompt and env evidence', () => { + const warning = buildRuntimeLaunchWarning( + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'high', + fastMode: 'on', + }, + { + CLAUDE_CODE_USE_OPENAI: '1', + CLAUDE_CODE_ENTRY_PROVIDER: 'codex', + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: '1', + }, + { + promptSize: { chars: 12345, lines: 7 }, + expectedMembersCount: 3, + } + ); + + expect(warning).toContain('Launch runtime: Codex'); + expect(warning).toContain('gpt-5.4'); + expect(warning).toContain('high'); + expect(warning).toContain('fast on'); + expect(warning).toContain('backend codex-native'); + expect(warning).toContain('prompt 12,345 chars/7 lines'); + expect(warning).toContain('members 3'); + expect(warning).toContain( + 'env USE_OPENAI, ENTRY_PROVIDER=codex, CODEX_BACKEND=codex-native, FORCE_PROCESS_TEAMMATES' + ); + }); + + it('includes Gemini auth diagnostics only for Gemini launch warnings', () => { + const geminiRuntimeAuth: GeminiRuntimeAuthState = { + authenticated: true, + authMethod: 'adc_authorized_user', + resolvedBackend: 'cli-sdk', + projectId: 'agent-teams-dev', + statusMessage: null, + }; + + expect( + buildRuntimeLaunchWarning( + { + providerId: 'gemini', + providerBackendId: 'cli-sdk', + model: 'gemini-2.5-pro', + effort: undefined, + fastMode: undefined, + }, + { + CLAUDE_CODE_USE_GEMINI: '1', + CLAUDE_CODE_GEMINI_BACKEND: 'cli-sdk', + }, + { geminiRuntimeAuth } + ) + ).toContain('auth adc_authorized_user/cli-sdk'); + + expect( + buildRuntimeLaunchWarning( + { + providerId: 'anthropic', + providerBackendId: undefined, + model: undefined, + effort: undefined, + fastMode: undefined, + }, + {}, + { geminiRuntimeAuth } + ) + ).not.toContain('auth adc_authorized_user/cli-sdk'); + }); + + it('logs a structured launch snapshot with nullable env fields and launch identity', () => { + const messages: string[] = []; + const launchIdentity: ProviderModelLaunchIdentity = { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.4', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'gpt-5.4', + catalogId: null, + catalogSource: 'runtime', + catalogFetchedAt: null, + selectedEffort: 'medium', + resolvedEffort: 'medium', + selectedFastMode: 'on', + resolvedFastMode: true, + fastResolutionReason: 'explicit', + }; + + logRuntimeLaunchSnapshot( + { info: (message) => messages.push(message) }, + 'atlas', + '/usr/local/bin/claude', + ['--model', 'gpt-5.4'], + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + fastMode: 'on', + }, + { CLAUDE_CODE_USE_OPENAI: '1' }, + { + promptSize: { chars: 10, lines: 2 }, + expectedMembersCount: 2, + launchIdentity, + } + ); + + expect(messages).toHaveLength(1); + const prefix = '[atlas] Launch runtime snapshot '; + expect(messages[0]?.startsWith(prefix)).toBe(true); + const snapshot = JSON.parse(messages[0]!.slice(prefix.length)) as { + providerId: string; + providerBackendId: string | null; + model: string | null; + effort: string | null; + fastMode: string | null; + configuredBackend: string | null; + promptSize: { chars: number; lines: number } | null; + expectedMembersCount: number | null; + launchIdentity: ProviderModelLaunchIdentity | null; + geminiRuntimeAuth: unknown; + env: Record; + args: string[]; + claudePath: string; + }; + + expect(snapshot).toMatchObject({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + fastMode: 'on', + configuredBackend: 'codex-native', + promptSize: { chars: 10, lines: 2 }, + expectedMembersCount: 2, + launchIdentity, + geminiRuntimeAuth: null, + args: ['--model', 'gpt-5.4'], + claudePath: '/usr/local/bin/claude', + }); + expect(snapshot.env.CLAUDE_CODE_USE_OPENAI).toBe('1'); + expect(snapshot.env.CLAUDE_CODE_USE_GEMINI).toBeNull(); + expect(snapshot.env.CLAUDE_CONFIG_DIR).toBeNull(); + }); +}); diff --git a/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts b/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts index 295de18d..9e601d96 100644 --- a/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts +++ b/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from 'vitest'; - import { getOpenCodeMixedProviderProvisioningError, shouldWarnOnMissingRegisteredMember, shouldWarnOnUnreadableMemberAuditConfig, } from '@main/services/team/TeamProvisioningService'; +import { describe, expect, it } from 'vitest'; describe('TeamProvisioningService audit warning policy', () => { it('suppresses unreadable config warnings during the short post-accept grace window', () => {