From e3d0d560735ead2343f0987dea9866a4f0e96eef Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 11 May 2026 17:29:19 +0300 Subject: [PATCH] feat(team): capture launch failure artifacts --- runtime.lock.json | 12 +- src/main/ipc/teams.ts | 20 ++ .../team/TeamLaunchFailureArtifactPack.ts | 177 ++++++++++++++ .../services/team/TeamProvisioningService.ts | 229 ++---------------- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 9 + src/renderer/api/httpClient.ts | 7 + .../team/ProvisioningProgressBlock.tsx | 117 ++++++--- .../components/team/TeamProvisioningPanel.tsx | 2 + .../team/dialogs/LaunchTeamDialog.tsx | 57 ++--- src/shared/types/api.ts | 5 + src/shared/types/team.ts | 31 ++- .../TeamLaunchFailureArtifactPack.test.ts | 32 ++- .../team/TeamProvisioningService.test.ts | 25 +- 14 files changed, 424 insertions(+), 302 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index a1350554..0bd8b72d 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -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" } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 2f94e68e..7b5487ce 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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> { + 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 diff --git a/src/main/services/team/TeamLaunchFailureArtifactPack.ts b/src/main/services/team/TeamLaunchFailureArtifactPack.ts index 38b69201..40812bfd 100644 --- a/src/main/services/team/TeamLaunchFailureArtifactPack.ts +++ b/src/main/services/team/TeamLaunchFailureArtifactPack.ts @@ -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; @@ -332,6 +334,181 @@ async function readBoundedTextFile(sourcePath: string): Promise<{ text?: string; } } +async function readDiagnosticsCopyFile( + label: string, + sourcePath: string +): Promise { + 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 { + 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); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 19ce341e..271e6822 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3488,68 +3488,6 @@ function buildEffectiveTeamMemberSpecs( return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults)); } -function shouldSkipResumeForProviderRuntimeChange( - request: Pick, - config: Record, - 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[]) - : []; - 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; - 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; @@ -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(() => { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 209c0f06..05c70265 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 576c657f..e2e9a99f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_PROVISIONING_STATUS, runId); }, + getLaunchFailureDiagnostics: async (teamName: string, runId?: string) => { + return invokeIpcWithResult( + TEAM_LAUNCH_FAILURE_DIAGNOSTICS, + teamName, + runId + ); + }, cancelProvisioning: async (runId: string) => { return invokeIpcWithResult(TEAM_CANCEL_PROVISIONING, runId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index f319864d..82d9e094 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => { throw new Error('Team provisioning is not available in browser mode'); }, + getLaunchFailureDiagnostics: async ( + _teamName: string, + _runId?: string + ): Promise => { + throw new Error('Launch failure diagnostics are not available in browser mode'); + }, cancelProvisioning: async (_runId: string): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index b7285d41..91e235f1 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -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(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 { diff --git a/src/renderer/components/team/TeamProvisioningPanel.tsx b/src/renderer/components/team/TeamProvisioningPanel.tsx index 9a816137..6f5514c7 100644 --- a/src/renderer/components/team/TeamProvisioningPanel.tsx +++ b/src/renderer/components/team/TeamProvisioningPanel.tsx @@ -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} diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index b5296d36..80db5320 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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(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', ''); 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 ) : null} -
- setClearContext(checked === true)} - /> - -
- {clearContext && ( -
-
- -

- The team lead will start a new session without resuming previous context. - All accumulated session memory and conversation history will not be - available. -

-
+
+
+ +

+ Team relaunch starts a fresh lead session. Durable team state, task board, + and member configuration are rehydrated into the launch prompt. +

- )} +
Promise; createTeam: (request: TeamCreateRequest) => Promise; getProvisioningStatus: (runId: string) => Promise; + getLaunchFailureDiagnostics: ( + teamName: string, + runId?: string + ) => Promise; cancelProvisioning: (runId: string) => Promise; sendMessage: (teamName: string, request: SendMessageRequest) => Promise; getOpenCodeRuntimeDeliveryStatus: ( diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 57b3e173..59435726 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; diff --git a/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts b/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts index e82bbf18..f9a293e9 100644 --- a/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts +++ b/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts @@ -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, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 33a76845..11049072 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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); });