feat(team): capture launch failure artifacts

This commit is contained in:
777genius 2026-05-11 17:29:19 +03:00
parent 064f5b977d
commit e3d0d56073
14 changed files with 424 additions and 302 deletions

View file

@ -1,27 +1,27 @@
{
"version": "0.0.27",
"sourceRef": "v0.0.27",
"version": "0.0.28",
"sourceRef": "v0.0.28",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.27.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.28.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.27.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.28.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.27.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.28.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.27.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.28.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -48,6 +48,7 @@ import {
TEAM_INITIALIZE_GIT_REPOSITORY,
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
TEAM_LEAD_ACTIVITY,
TEAM_LEAD_CONTEXT,
TEAM_LIST,
@ -143,6 +144,7 @@ import {
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamConfigReader } from '../services/team/TeamConfigReader';
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
@ -215,6 +217,7 @@ import type {
TeamGetDataOptions,
TeamLaunchRequest,
TeamLaunchResponse,
TeamLaunchFailureDiagnosticsBundle,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProviderBackendId,
@ -702,6 +705,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
ipcMain.handle(TEAM_LAUNCH_FAILURE_DIAGNOSTICS, handleLaunchFailureDiagnostics);
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
ipcMain.handle(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, handleGetOpenCodeRuntimeDeliveryStatus);
@ -788,6 +792,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_CREATE);
ipcMain.removeHandler(TEAM_LAUNCH);
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
ipcMain.removeHandler(TEAM_LAUNCH_FAILURE_DIAGNOSTICS);
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
ipcMain.removeHandler(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS);
@ -2381,6 +2386,21 @@ async function handleProvisioningStatus(
);
}
async function handleLaunchFailureDiagnostics(
_event: IpcMainInvokeEvent,
teamName: unknown,
runId: unknown
): Promise<IpcResult<TeamLaunchFailureDiagnosticsBundle>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
const validatedRunId = typeof runId === 'string' && runId.trim() ? runId.trim() : undefined;
return wrapTeamHandler('launchFailureDiagnostics', () =>
readTeamLaunchFailureDiagnosticsBundle(validatedTeamName.value!, validatedRunId)
);
}
async function handleCancelProvisioning(
_event: IpcMainInvokeEvent,
runId: unknown

View file

@ -11,6 +11,7 @@ import type {
MemberSpawnStatusEntry,
PersistedTeamLaunchSnapshot,
TeamLaunchDiagnosticItem,
TeamLaunchFailureDiagnosticsBundle,
TeamMember,
TeamProviderBackendId,
TeamProviderId,
@ -24,6 +25,7 @@ const LATEST_ARTIFACT_FILE = 'latest.json';
const MAX_CLI_LOG_CHARS = 256_000;
const MAX_TRACE_CHARS = 128_000;
const MAX_COPIED_FILE_BYTES = 256 * 1024;
const MAX_DIAGNOSTICS_COPY_FILE_BYTES = 128 * 1024;
type JsonRecord = Record<string, unknown>;
@ -332,6 +334,181 @@ async function readBoundedTextFile(sourcePath: string): Promise<{ text?: string;
}
}
async function readDiagnosticsCopyFile(
label: string,
sourcePath: string
): Promise<TeamLaunchFailureDiagnosticsBundle['files'][number]> {
const read = await readBoundedTextFile(sourcePath);
if (read.text === undefined) {
return {
label,
path: sourcePath,
issue: read.issue ?? 'unreadable',
};
}
const text =
read.text.length > MAX_DIAGNOSTICS_COPY_FILE_BYTES
? `[truncated to last ${MAX_DIAGNOSTICS_COPY_FILE_BYTES} chars]\n${read.text.slice(
read.text.length - MAX_DIAGNOSTICS_COPY_FILE_BYTES
)}`
: read.text;
return {
label,
path: sourcePath,
content: redactLaunchFailureArtifactText(text).trimEnd(),
};
}
function parseJsonObject(text: string | undefined): JsonRecord | null {
if (!text) return null;
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as JsonRecord)
: null;
} catch {
return null;
}
}
function getString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value : undefined;
}
function getRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : null;
}
function resolveArtifactManifestPath(
teamDir: string,
latestJson: JsonRecord | null,
runId?: string
): { path?: string; issue?: string } {
if (!latestJson) {
return { issue: 'latest_json_unavailable' };
}
const latestRunId = getString(latestJson.runId);
if (runId && latestRunId && latestRunId !== runId) {
return { issue: `latest_run_mismatch:${latestRunId}` };
}
const manifestPath = getString(latestJson.manifestPath);
if (!manifestPath) {
return { issue: 'manifest_path_missing' };
}
try {
assertPathWithin(path.join(teamDir, ARTIFACTS_DIR_NAME), manifestPath);
} catch {
return { issue: 'manifest_path_outside_artifacts_dir' };
}
return { path: manifestPath };
}
export async function readTeamLaunchFailureDiagnosticsBundle(
teamName: string,
runId?: string
): Promise<TeamLaunchFailureDiagnosticsBundle> {
const teamDir = path.join(getTeamsBasePath(), teamName);
const latestPath = path.join(teamDir, ARTIFACTS_DIR_NAME, LATEST_ARTIFACT_FILE);
const latestFile = await readDiagnosticsCopyFile(
'launch-failure-artifacts/latest.json',
latestPath
);
const latestJson = parseJsonObject(latestFile.content);
const resolvedManifest = resolveArtifactManifestPath(teamDir, latestJson, runId);
const files: TeamLaunchFailureDiagnosticsBundle['files'] = [latestFile];
let manifestJson: JsonRecord | null = null;
if (resolvedManifest.path) {
const manifestFile = await readDiagnosticsCopyFile(
'launch-failure-artifacts/manifest.json',
resolvedManifest.path
);
files.push(manifestFile);
manifestJson = parseJsonObject(manifestFile.content);
} else {
files.push({
label: 'launch-failure-artifacts/manifest.json',
path:
getString(latestJson?.manifestPath) ??
path.join(teamDir, ARTIFACTS_DIR_NAME, 'manifest.json'),
issue: resolvedManifest.issue ?? 'manifest_unavailable',
});
}
files.push(
await readDiagnosticsCopyFile(
'bootstrap-journal.jsonl',
path.join(teamDir, 'bootstrap-journal.jsonl')
),
await readDiagnosticsCopyFile('launch-state.json', getTeamLaunchStatePath(teamName))
);
const classification = getRecord(manifestJson?.classification);
const bootstrapTransportBreadcrumb = getRecord(manifestJson?.bootstrapTransportBreadcrumb);
return {
teamName,
...(runId
? { runId }
: getString(latestJson?.runId)
? { runId: getString(latestJson?.runId) }
: {}),
latestPath,
...(getString(latestJson?.directory)
? { artifactDirectory: getString(latestJson?.directory) }
: {}),
...(resolvedManifest.path ? { manifestPath: resolvedManifest.path } : {}),
classification: classification
? {
code: getString(classification.code),
confidence:
typeof classification.confidence === 'number' ? classification.confidence : undefined,
evidence: Array.isArray(classification.evidence)
? classification.evidence.filter((item): item is string => typeof item === 'string')
: undefined,
}
: null,
bootstrapTransportBreadcrumb: bootstrapTransportBreadcrumb
? {
lastTransportStage:
typeof bootstrapTransportBreadcrumb.lastTransportStage === 'string'
? bootstrapTransportBreadcrumb.lastTransportStage
: bootstrapTransportBreadcrumb.lastTransportStage === null
? null
: undefined,
submitRejected:
typeof bootstrapTransportBreadcrumb.submitRejected === 'boolean'
? bootstrapTransportBreadcrumb.submitRejected
: undefined,
retryable:
typeof bootstrapTransportBreadcrumb.retryable === 'boolean'
? bootstrapTransportBreadcrumb.retryable
: bootstrapTransportBreadcrumb.retryable === null
? null
: undefined,
noStdinWarning:
typeof bootstrapTransportBreadcrumb.noStdinWarning === 'boolean'
? bootstrapTransportBreadcrumb.noStdinWarning
: undefined,
bootstrapSubmitted:
typeof bootstrapTransportBreadcrumb.bootstrapSubmitted === 'boolean'
? bootstrapTransportBreadcrumb.bootstrapSubmitted
: undefined,
evidence: Array.isArray(bootstrapTransportBreadcrumb.evidence)
? bootstrapTransportBreadcrumb.evidence.filter(
(item): item is string => typeof item === 'string'
)
: undefined,
}
: null,
files,
};
}
function getKnownLaunchArtifactSourceFiles(teamName: string): CopiedArtifactFile[] {
const bootstrapStatePath = getTeamBootstrapStatePath(teamName);
const teamDir = path.dirname(bootstrapStatePath);

View file

@ -3488,68 +3488,6 @@ function buildEffectiveTeamMemberSpecs(
return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults));
}
function shouldSkipResumeForProviderRuntimeChange(
request: Pick<TeamLaunchRequest, 'providerId' | 'providerBackendId' | 'model'>,
config: Record<string, unknown>,
persistedProviderBackendId?: string | null
): { skip: boolean; reason?: string } {
const providerId = normalizeTeamMemberProviderId(request.providerId);
if (providerId !== 'gemini' && providerId !== 'codex') {
return { skip: false };
}
const requestedBackendId =
migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || null;
const previousBackendId =
migrateProviderBackendId(providerId, persistedProviderBackendId?.trim()) || null;
if (requestedBackendId && previousBackendId && requestedBackendId !== previousBackendId) {
return {
skip: true,
reason: `runtime backend changed (${previousBackendId} -> ${requestedBackendId})`,
};
}
const members = Array.isArray(config.members)
? (config.members as Record<string, unknown>[])
: [];
const lead =
members.find((member) => isLeadMember(member)) ??
members.find((member) => {
const name = typeof member?.name === 'string' ? member.name.trim().toLowerCase() : '';
return name === 'team-lead';
});
if (!lead) {
return { skip: false };
}
const currentLeadProviderId =
normalizeTeamMemberProviderId(
typeof lead.providerId === 'string'
? lead.providerId
: typeof lead.provider === 'string'
? lead.provider
: providerId
) ?? providerId;
const requestedModel = request.model?.trim() || '';
const currentLeadModel = typeof lead.model === 'string' ? lead.model.trim() : '';
if (currentLeadProviderId !== providerId) {
return {
skip: true,
reason: `provider changed (${currentLeadProviderId} -> ${providerId})`,
};
}
if (requestedModel && currentLeadModel && requestedModel !== currentLeadModel) {
return {
skip: true,
reason: `model changed (${currentLeadModel} -> ${requestedModel})`,
};
}
return { skip: false };
}
function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members
.map((member) => {
@ -4637,8 +4575,10 @@ function buildDeterministicLaunchHydrationPrompt(
const isSolo = members.length === 0;
const projectName = path.basename(request.cwd);
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
const startupLabel = isResume ? 'resume/bootstrap' : 'launch/bootstrap';
const headerModeLabel = isResume ? 'Deterministic resume' : 'Deterministic launch';
const userPromptBlock = request.prompt?.trim()
? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n`
? `\nOriginal user instructions to apply after ${isResume ? 'resume' : 'startup'} is stable:\n${request.prompt.trim()}\n`
: '';
const hasOriginalUserPrompt = Boolean(request.prompt?.trim());
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
@ -4649,7 +4589,7 @@ function buildDeterministicLaunchHydrationPrompt(
members,
});
const nextSteps = isSolo
? `This reconnect/bootstrap step has already been completed deterministically by the runtime.
? `This ${startupLabel} step has already been completed deterministically by the runtime.
Do NOT call TeamCreate.
Do NOT use Agent to spawn or restore teammates.
Do NOT start implementation in this turn.
@ -4659,7 +4599,7 @@ ${
? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.'
: 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
}`
: `This reconnect/bootstrap step has already been completed deterministically by the runtime.
: `This ${startupLabel} step has already been completed deterministically by the runtime.
Do NOT call TeamCreate.
Do NOT use Agent to spawn or restore teammates.
Do NOT repeat the launch summary.
@ -4671,7 +4611,7 @@ ${
}
Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`;
return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
return `${startLabel} [${headerModeLabel} | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
You are running headless in a non-interactive CLI session. Do not ask questions.
You are "${leadName}", the team lead.
@ -18419,8 +18359,8 @@ export class TeamProvisioningService {
run,
'configuring',
run.deterministicBootstrap
? 'CLI running — deterministic reconnect in progress'
: 'CLI running reconnecting with teammates'
? 'CLI running - deterministic launch in progress'
: 'CLI running - reconnecting with teammates'
);
run.onProgress(run.progress);
}
@ -19789,135 +19729,19 @@ export class TeamProvisioningService {
providerId: request.providerId,
members: expectedMemberSpecs,
});
// Extract leadSessionId for session resume on reconnect.
// If a valid JSONL file exists for the previous session, we can resume it
// so the lead retains full context of prior work.
// When clearContext is true, skip resume entirely to start a fresh session.
let previousSessionId: string | undefined;
let skipResume = false;
// Deterministic launch always sends --team-bootstrap-spec. The orchestrator
// rejects combining that startup mode with --resume, so relaunch starts a
// fresh lead runtime session and restores operational context from durable
// team state instead of the previous transcript.
if (request.clearContext) {
skipResume = true;
logger.info(
`[${request.teamName}] clearContext requested — skipping session resume, starting fresh`
`[${request.teamName}] clearContext requested - starting fresh deterministic bootstrap session`
);
} else {
// Check persisted launch state: if the previous launch ended with no teammates
// ever spawned (all in 'starting' state), resuming would reconnect the lead but
// the CLI's deterministic bootstrap won't re-spawn dead teammates in reconnect
// mode. Skip resume so the CLI creates a fresh session that fully bootstraps.
const persistedLaunchState = await this.launchStateStore.read(request.teamName);
if (persistedLaunchState) {
const {
expectedMembers: prevExpected,
members: prevMembers,
launchPhase,
} = persistedLaunchState;
const teammateWasNeverSpawned = (
member:
| {
agentToolAccepted?: boolean;
firstSpawnAcceptedAt?: string;
runtimeAlive?: boolean;
bootstrapConfirmed?: boolean;
}
| undefined
): boolean => {
if (!member) return true;
const hasAcceptedSpawn =
member.agentToolAccepted === true ||
(typeof member.firstSpawnAcceptedAt === 'string' &&
member.firstSpawnAcceptedAt.trim().length > 0);
return (
!hasAcceptedSpawn &&
member.runtimeAlive !== true &&
member.bootstrapConfirmed !== true
);
};
const updatedAtMs = Date.parse(persistedLaunchState.updatedAt);
const activeLaunchLooksStale =
launchPhase === 'active' &&
(!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs >= MEMBER_LAUNCH_GRACE_MS);
const launchOutcomeIsSettledOrStale = launchPhase !== 'active' || activeLaunchLooksStale;
const hasPreviousExpectedTeammates = prevExpected.length > 0;
const previousTeammates = prevExpected.map((name) => prevMembers[name]);
const staleActiveLaunchHasNoLiveTeammates =
activeLaunchLooksStale &&
hasPreviousExpectedTeammates &&
!previousTeammates.some(
(member) => member?.runtimeAlive === true || member?.bootstrapConfirmed === true
);
const allTeammatesNeverSpawned =
launchOutcomeIsSettledOrStale &&
hasPreviousExpectedTeammates &&
previousTeammates.every(teammateWasNeverSpawned);
if (allTeammatesNeverSpawned || staleActiveLaunchHasNoLiveTeammates) {
skipResume = true;
logger.info(
`[${request.teamName}] Previous launch cannot be resumed safely - ` +
`skipping session resume to allow full bootstrap`
);
}
}
}
if (!skipResume) {
try {
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
const persistedTeamMeta = await this.teamMetaStore
.getMeta(request.teamName)
.catch(() => null);
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(
request,
configParsed,
persistedTeamMeta?.providerBackendId ?? null
);
if (resumeGuard.skip) {
logger.info(
`[${request.teamName}] Skipping session resume — ${resumeGuard.reason ?? 'runtime changed'}`
);
} else if (
typeof configParsed.leadSessionId === 'string' &&
configParsed.leadSessionId.trim().length > 0
) {
const candidateId = configParsed.leadSessionId.trim();
const storedProjectPath =
typeof configParsed.projectPath === 'string' &&
configParsed.projectPath.trim().length > 0
? configParsed.projectPath.trim()
: null;
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
// If the project path changed, the old session JSONL won't be found by the CLI
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
if (
storedProjectPath &&
path.resolve(storedProjectPath) !== path.resolve(request.cwd)
) {
logger.info(
`[${request.teamName}] Project path changed: ${storedProjectPath}${request.cwd}. ` +
`Skipping session resume — sessions are per-project.`
);
} else {
const resumeProjectPath = storedProjectPath ?? request.cwd;
const projectId = encodePath(resumeProjectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
}
}
}
} catch {
logger.debug(
`[${request.teamName}] Failed to extract leadSessionId from config for resume`
);
}
logger.info(
`[${request.teamName}] Starting fresh deterministic bootstrap session because ` +
`--team-bootstrap-spec cannot be combined with --resume`
);
}
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
@ -20117,7 +19941,7 @@ export class TeamProvisioningService {
provisioningTraceLines: [],
lastProvisioningTraceKey: null,
provisioningOutputIndexByMessageId: new Map(),
detectedSessionId: previousSessionId ?? null,
detectedSessionId: null,
leadActivityState: 'active',
leadContextUsage: null,
authFailureRetried: false,
@ -20188,7 +20012,7 @@ export class TeamProvisioningService {
request,
effectiveMemberSpecs,
existingTasks,
Boolean(previousSessionId)
false
);
const promptSize = getPromptSizeSummary(prompt);
let child: ReturnType<typeof spawn>;
@ -20289,12 +20113,6 @@ export class TeamProvisioningService {
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
];
if (previousSessionId) {
launchArgs.push('--resume', previousSessionId);
logger.info(
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
);
}
const launchModelArg = getLaunchModelArg(
resolveTeamProviderId(request.providerId),
request.model,
@ -20344,8 +20162,8 @@ export class TeamProvisioningService {
expectedMembersCount: effectiveMemberSpecs.length,
launchIdentity,
});
// --resume is added above when a valid previous session JSONL exists.
// Without it, CLI creates a fresh session ID automatically.
// Deterministic bootstrap launches fresh because --team-bootstrap-spec and
// --resume are not a supported orchestrator combination.
emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn');
await this.teamMetaStore.writeMeta(request.teamName, {
displayName: syntheticRequest.displayName,
@ -20417,8 +20235,7 @@ export class TeamProvisioningService {
throw error;
}
const resumeHint = previousSessionId ? ' (resuming previous session)' : '';
updateProgress(run, 'spawning', `Starting Claude CLI process for team launch${resumeHint}`, {
updateProgress(run, 'spawning', 'Starting Claude CLI process for team launch', {
pid: child.pid ?? undefined,
warnings: mergeProvisioningWarnings(run.progress.warnings, runtimeWarning),
});
@ -20444,7 +20261,7 @@ export class TeamProvisioningService {
// For launch, skip the filesystem monitor — files (config, inboxes, tasks)
// already exist from the previous run and would trigger immediate false
// completion on the first poll. Rely on stream-json result.success instead.
updateProgress(run, 'configuring', 'CLI running — deterministic reconnect in progress');
updateProgress(run, 'configuring', 'CLI running - deterministic launch in progress');
run.onProgress(run.progress);
run.timeoutHandle = setTimeout(() => {

View file

@ -276,6 +276,9 @@ export const TEAM_CREATE_INITIAL_GIT_COMMIT = 'team:createInitialGitCommit';
/** Get provisioning status by runId */
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus';
/** Read bounded redacted launch failure diagnostics for support copy */
export const TEAM_LAUNCH_FAILURE_DIAGNOSTICS = 'team:launchFailureDiagnostics';
/** Cancel running provisioning by runId */
export const TEAM_CANCEL_PROVISIONING = 'team:cancelProvisioning';

View file

@ -155,6 +155,7 @@ import {
TEAM_INITIALIZE_GIT_REPOSITORY,
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
TEAM_LEAD_ACTIVITY,
TEAM_LEAD_CONTEXT,
TEAM_LIST,
@ -320,6 +321,7 @@ import type {
TeamGetDataOptions,
TeamLaunchRequest,
TeamLaunchResponse,
TeamLaunchFailureDiagnosticsBundle,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningModelVerificationMode,
@ -951,6 +953,13 @@ const electronAPI: ElectronAPI = {
getProvisioningStatus: async (runId: string) => {
return invokeIpcWithResult<TeamProvisioningProgress>(TEAM_PROVISIONING_STATUS, runId);
},
getLaunchFailureDiagnostics: async (teamName: string, runId?: string) => {
return invokeIpcWithResult<TeamLaunchFailureDiagnosticsBundle>(
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
teamName,
runId
);
},
cancelProvisioning: async (runId: string) => {
return invokeIpcWithResult<void>(TEAM_CANCEL_PROVISIONING, runId);
},

View file

@ -74,6 +74,7 @@ import type {
TeamCreateRequest,
TeamCreateResponse,
TeamGetDataOptions,
TeamLaunchFailureDiagnosticsBundle,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
@ -819,6 +820,12 @@ export class HttpAPIClient implements ElectronAPI {
getProvisioningStatus: async (_runId: string): Promise<TeamProvisioningProgress> => {
throw new Error('Team provisioning is not available in browser mode');
},
getLaunchFailureDiagnostics: async (
_teamName: string,
_runId?: string
): Promise<TeamLaunchFailureDiagnosticsBundle> => {
throw new Error('Launch failure diagnostics are not available in browser mode');
},
cancelProvisioning: async (_runId: string): Promise<void> => {
throw new Error('Team provisioning is not available in browser mode');
},

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { renderLinkifiedText } from '@renderer/utils/linkifiedText';
@ -23,7 +24,7 @@ import { StepProgressBar } from './StepProgressBar';
import type { StepProgressBarStep } from './StepProgressBar';
import type { MemberLaunchDiagnosticsPayload } from '@renderer/utils/memberLaunchDiagnostics';
import type { TeamLaunchDiagnosticItem } from '@shared/types';
import type { TeamLaunchDiagnosticItem, TeamLaunchFailureDiagnosticsBundle } from '@shared/types';
/** Pre-built step definitions for the provisioning stepper. */
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
@ -69,6 +70,9 @@ export interface ProvisioningProgressBlockProps {
onDismiss?: (() => void) | null;
/** ISO timestamp when provisioning started */
startedAt?: string;
/** Team/run identity used to enrich copied launch diagnostics with artifact pack data. */
teamName?: string;
runId?: string;
/** PID of the CLI process */
pid?: number;
/** CLI logs captured during launch */
@ -202,6 +206,49 @@ function formatMemberDiagnosticsCopy(
return JSON.stringify(items, null, 2);
}
function formatLaunchFailureArtifactCopy(
bundle: TeamLaunchFailureDiagnosticsBundle | null | undefined,
error?: string | null
): string {
if (error) {
return `Failed to read launch failure artifact bundle: ${error}`;
}
if (!bundle) {
return '(none)';
}
const parts = [
`teamName: ${bundle.teamName}`,
`runId: ${formatOptionalValue(bundle.runId)}`,
`latestPath: ${bundle.latestPath}`,
`artifactDirectory: ${formatOptionalValue(bundle.artifactDirectory)}`,
`manifestPath: ${formatOptionalValue(bundle.manifestPath)}`,
'',
'classification:',
bundle.classification ? JSON.stringify(bundle.classification, null, 2) : '(none)',
'',
'bootstrapTransportBreadcrumb:',
bundle.bootstrapTransportBreadcrumb
? JSON.stringify(bundle.bootstrapTransportBreadcrumb, null, 2)
: '(none)',
'',
'files:',
];
for (const file of bundle.files) {
parts.push(
'',
`--- ${file.label}`,
`path: ${file.path}`,
file.issue ? `issue: ${file.issue}` : 'issue: (none)',
'content:',
file.content?.trim() || '(empty)'
);
}
return parts.join('\n');
}
function buildProvisioningDiagnosticsCopy(input: {
title: string;
message?: string | null;
@ -216,6 +263,8 @@ function buildProvisioningDiagnosticsCopy(input: {
cliLogsTail?: string;
launchDiagnostics?: TeamLaunchDiagnosticItem[];
memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
launchFailureArtifact?: TeamLaunchFailureDiagnosticsBundle | null;
launchFailureArtifactError?: string | null;
}): string {
const payload = [
'# Team provisioning diagnostics',
@ -237,6 +286,9 @@ function buildProvisioningDiagnosticsCopy(input: {
'## Member launch snapshots',
formatMemberDiagnosticsCopy(input.memberDiagnostics),
'',
'## Launch failure artifact bundle',
formatLaunchFailureArtifactCopy(input.launchFailureArtifact, input.launchFailureArtifactError),
'',
'## Live output',
input.liveOutput?.trim() || '(empty)',
'',
@ -262,6 +314,8 @@ export const ProvisioningProgressBlock = ({
successMessageSeverity = 'success',
onDismiss,
startedAt,
teamName,
runId,
pid,
cliLogsTail,
assistantOutput,
@ -279,39 +333,6 @@ export const ProvisioningProgressBlock = ({
const copyResetTimerRef = useRef<number | null>(null);
const isError = tone === 'error';
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
const diagnosticsCopyText = useMemo(
() =>
buildProvisioningDiagnosticsCopy({
title,
message,
messageSeverity,
tone,
startedAt,
elapsed,
pid,
currentStepIndex,
errorStepIndex,
liveOutput: displayAssistantOutput,
cliLogsTail,
launchDiagnostics,
memberDiagnostics,
}),
[
title,
message,
messageSeverity,
tone,
startedAt,
elapsed,
pid,
currentStepIndex,
errorStepIndex,
displayAssistantOutput,
cliLogsTail,
launchDiagnostics,
memberDiagnostics,
]
);
const visibleLaunchDiagnostics =
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
[];
@ -357,6 +378,32 @@ export const ProvisioningProgressBlock = ({
setDiagnosticsCopied(false);
return;
}
let launchFailureArtifact: TeamLaunchFailureDiagnosticsBundle | null = null;
let launchFailureArtifactError: string | null = null;
if (teamName) {
try {
launchFailureArtifact = await api.teams.getLaunchFailureDiagnostics(teamName, runId);
} catch (error) {
launchFailureArtifactError = error instanceof Error ? error.message : String(error);
}
}
const diagnosticsCopyText = buildProvisioningDiagnosticsCopy({
title,
message,
messageSeverity,
tone,
startedAt,
elapsed,
pid,
currentStepIndex,
errorStepIndex,
liveOutput: displayAssistantOutput,
cliLogsTail,
launchDiagnostics,
memberDiagnostics,
launchFailureArtifact,
launchFailureArtifactError,
});
try {
await navigator.clipboard.writeText(diagnosticsCopyText);
} catch {

View file

@ -135,6 +135,8 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
}
loading={showRunningState}
startedAt={presentation.progress.startedAt}
teamName={teamName}
runId={presentation.progress.runId}
pid={presentation.progress.pid}
cliLogsTail={presentation.progress.cliLogsTail}
assistantOutput={presentation.progress.assistantOutput}

View file

@ -31,7 +31,6 @@ import {
} from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
import {
Dialog,
@ -82,7 +81,6 @@ import {
ChevronRight,
Info,
Loader2,
RotateCcw,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -452,7 +450,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [limitContext, setLimitContextRaw] = useState(
() => localStorage.getItem('team:lastLimitContext') === 'true'
);
const [clearContext, setClearContext] = useState(false);
const [conflictDismissed, setConflictDismissed] = useState(false);
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
@ -715,7 +712,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
setClearContext(false);
setConflictDismissed(false);
setMembersDrafts([]);
setSyncModelsWithLead(false);
@ -1811,7 +1807,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} else if (selectedProviderId === 'codex') {
args.push(...buildCodexFastModeArgs(codexFastModeResolution?.resolvedFastMode));
}
if (!clearContext) args.push('--resume', '<previous>');
return args;
}, [
anthropicFastModeResolution?.resolvedFastMode,
@ -1823,7 +1818,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
effectiveAnthropicRuntimeLimitContext,
selectedEffort,
selectedProviderId,
clearContext,
runtimeProviderStatusById,
]);
@ -1854,7 +1848,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary.push('Anthropic limited to 200K context');
}
if (skipPermissions) summary.push('Auto-approve tools');
if (clearContext) summary.push('Fresh session');
summary.push('Fresh lead session');
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args');
return summary;
@ -1869,7 +1863,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
anthropicProviderFastModeDefault,
effectiveAnthropicRuntimeLimitContext,
skipPermissions,
clearContext,
worktreeEnabled,
worktreeName,
customArgs,
@ -2117,7 +2110,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
? selectedFastMode
: undefined,
limitContext: effectiveAnthropicRuntimeLimitContext,
clearContext: clearContext || undefined,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
@ -2680,39 +2672,22 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div>
</div>
) : null}
<div className="flex items-center gap-2">
<Checkbox
id="clear-context"
checked={clearContext}
onCheckedChange={(checked) => setClearContext(checked === true)}
/>
<Label
htmlFor="clear-context"
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
>
<RotateCcw className="size-3 shrink-0" />
Clear context (fresh session)
</Label>
</div>
{clearContext && (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
The team lead will start a new session without resuming previous context.
All accumulated session memory and conversation history will not be
available.
</p>
</div>
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<Info className="mt-0.5 size-3.5 shrink-0" />
<p>
Team relaunch starts a fresh lead session. Durable team state, task board,
and member configuration are rehydrated into the launch prompt.
</p>
</div>
)}
</div>
</div>
<AdvancedCliSection

View file

@ -81,6 +81,7 @@ import type {
TeamGetDataOptions,
TeamLaunchRequest,
TeamLaunchResponse,
TeamLaunchFailureDiagnosticsBundle,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningModelVerificationMode,
@ -499,6 +500,10 @@ export interface TeamsAPI {
createInitialGitCommit: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
getLaunchFailureDiagnostics: (
teamName: string,
runId?: string
) => Promise<TeamLaunchFailureDiagnosticsBundle>;
cancelProvisioning: (runId: string) => Promise<void>;
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
getOpenCodeRuntimeDeliveryStatus: (

View file

@ -991,7 +991,7 @@ export interface TeamLaunchRequest {
fastMode?: TeamFastMode;
/** When true, context window is limited to 200K tokens instead of the default. */
limitContext?: boolean;
/** When true, skip --resume and start a fresh session (clears context memory). */
/** Legacy flag retained for compatibility. Deterministic bootstrap launches fresh today. */
clearContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
skipPermissions?: boolean;
@ -1479,6 +1479,35 @@ export interface TeamLaunchDiagnosticItem {
observedAt: string;
}
export interface TeamLaunchFailureDiagnosticsFile {
label: string;
path: string;
content?: string;
issue?: string;
}
export interface TeamLaunchFailureDiagnosticsBundle {
teamName: string;
runId?: string;
latestPath: string;
artifactDirectory?: string;
manifestPath?: string;
classification?: {
code?: string;
confidence?: number;
evidence?: string[];
} | null;
bootstrapTransportBreadcrumb?: {
lastTransportStage?: string | null;
submitRejected?: boolean;
retryable?: boolean | null;
noStdinWarning?: boolean;
bootstrapSubmitted?: boolean;
evidence?: string[];
} | null;
files: TeamLaunchFailureDiagnosticsFile[];
}
export interface TeamRuntimeState {
teamName: string;
isAlive: boolean;

View file

@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
classifyLaunchFailureArtifact,
extractLaunchBootstrapTransportBreadcrumb,
readTeamLaunchFailureDiagnosticsBundle,
redactLaunchFailureArtifactText,
writeTeamLaunchFailureArtifactPack,
} from '../../../../src/main/services/team/TeamLaunchFailureArtifactPack';
@ -103,7 +104,10 @@ describe('TeamLaunchFailureArtifactPack', () => {
expect(manifest.artifactFiles).toContain('launch-state.json');
expect(manifest.progress.error).toContain('[REDACTED]');
const copiedLaunchState = await fs.readFile(path.join(result.directory, 'launch-state.json'), 'utf8');
const copiedLaunchState = await fs.readFile(
path.join(result.directory, 'launch-state.json'),
'utf8'
);
expect(copiedLaunchState).toContain('[REDACTED_ANTHROPIC_API_KEY]');
expect(() => JSON.parse(copiedLaunchState)).not.toThrow();
expect(copiedLaunchState).toContain('"token":"[REDACTED]"');
@ -117,6 +121,29 @@ describe('TeamLaunchFailureArtifactPack', () => {
await fs.readFile(path.join(teamDir, 'launch-failure-artifacts', 'latest.json'), 'utf8')
) as { manifestPath: string };
expect(latest.manifestPath).toBe(result.manifestPath);
const bundle = await readTeamLaunchFailureDiagnosticsBundle(teamName, runId);
expect(bundle).toMatchObject({
teamName,
runId,
manifestPath: result.manifestPath,
classification: { code: 'provider_auth' },
bootstrapTransportBreadcrumb: { submitRejected: false },
});
expect(bundle.files.map((file) => file.label)).toEqual([
'launch-failure-artifacts/latest.json',
'launch-failure-artifacts/manifest.json',
'bootstrap-journal.jsonl',
'launch-state.json',
]);
expect(
bundle.files.find((file) => file.label === 'bootstrap-journal.jsonl')?.content
).toContain('"event":"started"');
const launchStateContent = bundle.files.find(
(file) => file.label === 'launch-state.json'
)?.content;
expect(launchStateContent).toContain('[REDACTED_ANTHROPIC_API_KEY]');
expect(launchStateContent).not.toContain('sk-ant-');
});
it('redacts common bearer and token-shaped secrets', () => {
@ -140,7 +167,8 @@ describe('TeamLaunchFailureArtifactPack', () => {
expect(classifyLaunchFailureArtifact(input).code).toBe('transport_rejected');
expect(extractLaunchBootstrapTransportBreadcrumb(input)).toMatchObject({
lastTransportStage: 'bootstrap_submit_rejected: submit rejected by local prompt handler retryable=true',
lastTransportStage:
'bootstrap_submit_rejected: submit rejected by local prompt handler retryable=true',
submitRejected: true,
retryable: true,
noStdinWarning: true,

View file

@ -14062,7 +14062,7 @@ describe('TeamProvisioningService', () => {
).toBe('Questions (2): First question with extra spacing.');
});
it('skips --resume when the persisted launch state shows no teammate ever spawned', async () => {
it('skips --resume for deterministic bootstrap when previous launch state has no spawned teammates', async () => {
allowConsoleLogs();
const teamName = 'resume-skip-team';
const leadSessionId = 'lead-session-skip';
@ -14114,7 +14114,7 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).not.toContain(leadSessionId);
});
it('skips --resume when a stale active launch state shows no teammate ever spawned', async () => {
it('skips --resume for deterministic bootstrap when previous active launch is stale', async () => {
allowConsoleLogs();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z'));
@ -14178,7 +14178,7 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).not.toContain(leadSessionId);
});
it('skips --resume when a stale active launch has accepted but no live teammates', async () => {
it('skips --resume for deterministic bootstrap when stale active launch has no live teammates', async () => {
allowConsoleLogs();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z'));
@ -14244,7 +14244,7 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).not.toContain(leadSessionId);
});
it('keeps --resume when a teammate had an accepted spawn before failing bootstrap', async () => {
it('skips --resume with deterministic bootstrap even after an accepted failed spawn', async () => {
allowConsoleLogs();
const teamName = 'resume-keep-team';
const leadSessionId = 'lead-session-keep';
@ -14291,11 +14291,11 @@ describe('TeamProvisioningService', () => {
);
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toContain('--resume');
expect(launchArgs).toContain(leadSessionId);
expect(launchArgs).not.toContain('--resume');
expect(launchArgs).not.toContain(leadSessionId);
});
it('keeps --resume when a persisted legacy Codex backend normalizes to codex-native', async () => {
it('skips --resume with deterministic bootstrap after Codex backend normalization', async () => {
allowConsoleLogs();
const teamName = 'resume-backend-change-team';
const leadSessionId = 'lead-session-backend-change';
@ -14348,11 +14348,11 @@ describe('TeamProvisioningService', () => {
const launchArgs = vi.mocked(spawnCli).mock.calls.at(-1)?.[1] as string[];
expect(launchArgs).toBeTruthy();
expect(launchArgs).toContain('--resume');
expect(launchArgs).toContain(leadSessionId);
expect(launchArgs).not.toContain('--resume');
expect(launchArgs).not.toContain(leadSessionId);
});
it('seeds the current lead session id immediately when launch resumes an existing session', async () => {
it('does not seed the previous lead session id when deterministic bootstrap skips resume', async () => {
allowConsoleLogs();
const teamName = 'resume-seed-session-team';
const leadSessionId = 'lead-session-seeded';
@ -14388,7 +14388,10 @@ describe('TeamProvisioningService', () => {
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {});
expect(svc.getCurrentLeadSessionId(teamName)).toBe(leadSessionId);
const launchArgs = vi.mocked(spawnCli).mock.calls.at(-1)?.[1] as string[];
expect(launchArgs).not.toContain('--resume');
expect((svc as any).pathExists).not.toHaveBeenCalled();
expect(svc.getCurrentLeadSessionId(teamName)).toBeNull();
await svc.cancelProvisioning(runId);
});