refactor(team): extract provisioning service policies
This commit is contained in:
parent
57c209a828
commit
95652c8990
22 changed files with 4133 additions and 2050 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, { bootstrapConfirmed?: boolean } | undefined>;
|
||||
}
|
||||
|
||||
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<string, unknown>)[key];
|
||||
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractStructuredCliError(parsed: Record<string, unknown>): 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<string, unknown>;
|
||||
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'}` };
|
||||
}
|
||||
|
|
@ -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<string, string>();
|
||||
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"`;
|
||||
}
|
||||
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, MemberSpawnStatusEntry> | 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];
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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<MemberSpawnStatusEntry, 'agentToolAccepted' | 'firstSpawnAcceptedAt'> | 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<string, MemberSpawnStatusEntry>
|
||||
): 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.`;
|
||||
}
|
||||
|
|
@ -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<string, PersistedTeamLaunchMemberState> = { ...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]));
|
||||
}
|
||||
|
|
@ -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<string, TeamRuntimeMemberLaunchEvidence>
|
||||
): 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<PersistedTeamLaunchMemberState, 'runtimePid' | 'runtimeSessionId' | 'livenessKind'>
|
||||
| Pick<TeamRuntimeMemberLaunchEvidence, 'runtimePid' | 'sessionId' | 'livenessKind'>
|
||||
| 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<TeamRuntimeMemberLaunchEvidence, 'livenessKind'> | undefined
|
||||
): boolean {
|
||||
return (
|
||||
value?.livenessKind === 'runtime_process' ||
|
||||
value?.livenessKind === 'runtime_process_candidate' ||
|
||||
value?.livenessKind === 'permission_blocked'
|
||||
);
|
||||
}
|
||||
|
||||
export function hasOpenCodeRuntimeEntryHandle(
|
||||
value:
|
||||
| Pick<TeamAgentRuntimeEntry, 'pid' | 'runtimePid' | 'runtimeSessionId' | 'livenessKind'>
|
||||
| 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<PersistedTeamLaunchMemberState, 'firstSpawnAcceptedAt' | 'diagnostics'>
|
||||
): 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
146
test/main/services/team/TeamProvisioningDirectRestart.test.ts
Normal file
146
test/main/services/team/TeamProvisioningDirectRestart.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>): MemberSpawnStatusEntry {
|
||||
return {
|
||||
status: 'waiting',
|
||||
launchState: 'starting',
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRun(entries: Array<[string, Partial<MemberSpawnStatusEntry>]>, isLaunch = true) {
|
||||
return {
|
||||
isLaunch,
|
||||
memberSpawnStatuses: new Map(
|
||||
entries.map(([memberName, entry]) => [memberName, spawnEntry(entry)])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceTrustExecution(
|
||||
overrides: Partial<WorkspaceTrustExecutionResult>
|
||||
): 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): 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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}
|
||||
): 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> = {}
|
||||
): 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> = {}
|
||||
): 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<TeamRuntimeMemberLaunchEvidence> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TeamProviderId, string>([
|
||||
['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<string, string | null>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue