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", "version": "0.0.28",
"sourceRef": "v0.0.27", "sourceRef": "v0.0.28",
"sourceRepository": "777genius/agent_teams_orchestrator", "sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai", "releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v1.2.0", "releaseTag": "v1.2.0",
"assets": { "assets": {
"darwin-arm64": { "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", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"darwin-x64": { "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", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"linux-x64": { "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", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"win32-x64": { "win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.27.zip", "file": "agent-teams-runtime-win32-x64-v0.0.28.zip",
"archiveKind": "zip", "archiveKind": "zip",
"binaryName": "claude-multimodel.exe" "binaryName": "claude-multimodel.exe"
} }

View file

@ -48,6 +48,7 @@ import {
TEAM_INITIALIZE_GIT_REPOSITORY, TEAM_INITIALIZE_GIT_REPOSITORY,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
TEAM_LEAD_CONTEXT, TEAM_LEAD_CONTEXT,
TEAM_LIST, TEAM_LIST,
@ -143,6 +144,7 @@ import {
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages'; import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamConfigReader } from '../services/team/TeamConfigReader'; import { TeamConfigReader } from '../services/team/TeamConfigReader';
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
@ -215,6 +217,7 @@ import type {
TeamGetDataOptions, TeamGetDataOptions,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamLaunchFailureDiagnosticsBundle,
TeamMemberActivityMeta, TeamMemberActivityMeta,
TeamMessageNotificationData, TeamMessageNotificationData,
TeamProviderBackendId, TeamProviderBackendId,
@ -702,6 +705,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus); ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
ipcMain.handle(TEAM_LAUNCH_FAILURE_DIAGNOSTICS, handleLaunchFailureDiagnostics);
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning); ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
ipcMain.handle(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, handleGetOpenCodeRuntimeDeliveryStatus); 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_CREATE);
ipcMain.removeHandler(TEAM_LAUNCH); ipcMain.removeHandler(TEAM_LAUNCH);
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS); ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
ipcMain.removeHandler(TEAM_LAUNCH_FAILURE_DIAGNOSTICS);
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE); ipcMain.removeHandler(TEAM_SEND_MESSAGE);
ipcMain.removeHandler(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS); 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( async function handleCancelProvisioning(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
runId: unknown runId: unknown

View file

@ -11,6 +11,7 @@ import type {
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
PersistedTeamLaunchSnapshot, PersistedTeamLaunchSnapshot,
TeamLaunchDiagnosticItem, TeamLaunchDiagnosticItem,
TeamLaunchFailureDiagnosticsBundle,
TeamMember, TeamMember,
TeamProviderBackendId, TeamProviderBackendId,
TeamProviderId, TeamProviderId,
@ -24,6 +25,7 @@ const LATEST_ARTIFACT_FILE = 'latest.json';
const MAX_CLI_LOG_CHARS = 256_000; const MAX_CLI_LOG_CHARS = 256_000;
const MAX_TRACE_CHARS = 128_000; const MAX_TRACE_CHARS = 128_000;
const MAX_COPIED_FILE_BYTES = 256 * 1024; const MAX_COPIED_FILE_BYTES = 256 * 1024;
const MAX_DIAGNOSTICS_COPY_FILE_BYTES = 128 * 1024;
type JsonRecord = Record<string, unknown>; 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[] { function getKnownLaunchArtifactSourceFiles(teamName: string): CopiedArtifactFile[] {
const bootstrapStatePath = getTeamBootstrapStatePath(teamName); const bootstrapStatePath = getTeamBootstrapStatePath(teamName);
const teamDir = path.dirname(bootstrapStatePath); const teamDir = path.dirname(bootstrapStatePath);

View file

@ -3488,68 +3488,6 @@ function buildEffectiveTeamMemberSpecs(
return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults)); 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 { function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members return members
.map((member) => { .map((member) => {
@ -4637,8 +4575,10 @@ function buildDeterministicLaunchHydrationPrompt(
const isSolo = members.length === 0; const isSolo = members.length === 0;
const projectName = path.basename(request.cwd); const projectName = path.basename(request.cwd);
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start'; 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() 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 hasOriginalUserPrompt = Boolean(request.prompt?.trim());
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
@ -4649,7 +4589,7 @@ function buildDeterministicLaunchHydrationPrompt(
members, members,
}); });
const nextSteps = isSolo 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 call TeamCreate.
Do NOT use Agent to spawn or restore teammates. Do NOT use Agent to spawn or restore teammates.
Do NOT start implementation in this turn. 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 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.' : '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 call TeamCreate.
Do NOT use Agent to spawn or restore teammates. Do NOT use Agent to spawn or restore teammates.
Do NOT repeat the launch summary. Do NOT repeat the launch summary.
@ -4671,7 +4611,7 @@ ${
} }
Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; 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 running headless in a non-interactive CLI session. Do not ask questions.
You are "${leadName}", the team lead. You are "${leadName}", the team lead.
@ -18419,8 +18359,8 @@ export class TeamProvisioningService {
run, run,
'configuring', 'configuring',
run.deterministicBootstrap run.deterministicBootstrap
? 'CLI running — deterministic reconnect in progress' ? 'CLI running - deterministic launch in progress'
: 'CLI running reconnecting with teammates' : 'CLI running - reconnecting with teammates'
); );
run.onProgress(run.progress); run.onProgress(run.progress);
} }
@ -19789,135 +19729,19 @@ export class TeamProvisioningService {
providerId: request.providerId, providerId: request.providerId,
members: expectedMemberSpecs, members: expectedMemberSpecs,
}); });
// Extract leadSessionId for session resume on reconnect. // Deterministic launch always sends --team-bootstrap-spec. The orchestrator
// If a valid JSONL file exists for the previous session, we can resume it // rejects combining that startup mode with --resume, so relaunch starts a
// so the lead retains full context of prior work. // fresh lead runtime session and restores operational context from durable
// When clearContext is true, skip resume entirely to start a fresh session. // team state instead of the previous transcript.
let previousSessionId: string | undefined;
let skipResume = false;
if (request.clearContext) { if (request.clearContext) {
skipResume = true;
logger.info( logger.info(
`[${request.teamName}] clearContext requested — skipping session resume, starting fresh` `[${request.teamName}] clearContext requested - starting fresh deterministic bootstrap session`
); );
} else { } else {
// Check persisted launch state: if the previous launch ended with no teammates logger.info(
// ever spawned (all in 'starting' state), resuming would reconnect the lead but `[${request.teamName}] Starting fresh deterministic bootstrap session because ` +
// the CLI's deterministic bootstrap won't re-spawn dead teammates in reconnect `--team-bootstrap-spec cannot be combined with --resume`
// 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`
);
}
} }
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json. // IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
@ -20117,7 +19941,7 @@ export class TeamProvisioningService {
provisioningTraceLines: [], provisioningTraceLines: [],
lastProvisioningTraceKey: null, lastProvisioningTraceKey: null,
provisioningOutputIndexByMessageId: new Map(), provisioningOutputIndexByMessageId: new Map(),
detectedSessionId: previousSessionId ?? null, detectedSessionId: null,
leadActivityState: 'active', leadActivityState: 'active',
leadContextUsage: null, leadContextUsage: null,
authFailureRetried: false, authFailureRetried: false,
@ -20188,7 +20012,7 @@ export class TeamProvisioningService {
request, request,
effectiveMemberSpecs, effectiveMemberSpecs,
existingTasks, existingTasks,
Boolean(previousSessionId) false
); );
const promptSize = getPromptSizeSummary(prompt); const promptSize = getPromptSizeSummary(prompt);
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
@ -20289,12 +20113,6 @@ export class TeamProvisioningService {
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), : ['--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( const launchModelArg = getLaunchModelArg(
resolveTeamProviderId(request.providerId), resolveTeamProviderId(request.providerId),
request.model, request.model,
@ -20344,8 +20162,8 @@ export class TeamProvisioningService {
expectedMembersCount: effectiveMemberSpecs.length, expectedMembersCount: effectiveMemberSpecs.length,
launchIdentity, launchIdentity,
}); });
// --resume is added above when a valid previous session JSONL exists. // Deterministic bootstrap launches fresh because --team-bootstrap-spec and
// Without it, CLI creates a fresh session ID automatically. // --resume are not a supported orchestrator combination.
emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn');
await this.teamMetaStore.writeMeta(request.teamName, { await this.teamMetaStore.writeMeta(request.teamName, {
displayName: syntheticRequest.displayName, displayName: syntheticRequest.displayName,
@ -20417,8 +20235,7 @@ export class TeamProvisioningService {
throw error; throw error;
} }
const resumeHint = previousSessionId ? ' (resuming previous session)' : ''; updateProgress(run, 'spawning', 'Starting Claude CLI process for team launch', {
updateProgress(run, 'spawning', `Starting Claude CLI process for team launch${resumeHint}`, {
pid: child.pid ?? undefined, pid: child.pid ?? undefined,
warnings: mergeProvisioningWarnings(run.progress.warnings, runtimeWarning), warnings: mergeProvisioningWarnings(run.progress.warnings, runtimeWarning),
}); });
@ -20444,7 +20261,7 @@ export class TeamProvisioningService {
// For launch, skip the filesystem monitor — files (config, inboxes, tasks) // For launch, skip the filesystem monitor — files (config, inboxes, tasks)
// already exist from the previous run and would trigger immediate false // already exist from the previous run and would trigger immediate false
// completion on the first poll. Rely on stream-json result.success instead. // 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.onProgress(run.progress);
run.timeoutHandle = setTimeout(() => { run.timeoutHandle = setTimeout(() => {

View file

@ -276,6 +276,9 @@ export const TEAM_CREATE_INITIAL_GIT_COMMIT = 'team:createInitialGitCommit';
/** Get provisioning status by runId */ /** Get provisioning status by runId */
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus'; 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 */ /** Cancel running provisioning by runId */
export const TEAM_CANCEL_PROVISIONING = 'team:cancelProvisioning'; export const TEAM_CANCEL_PROVISIONING = 'team:cancelProvisioning';

View file

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

View file

@ -74,6 +74,7 @@ import type {
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamGetDataOptions, TeamGetDataOptions,
TeamLaunchFailureDiagnosticsBundle,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMemberActivityMeta, TeamMemberActivityMeta,
@ -819,6 +820,12 @@ export class HttpAPIClient implements ElectronAPI {
getProvisioningStatus: async (_runId: string): Promise<TeamProvisioningProgress> => { getProvisioningStatus: async (_runId: string): Promise<TeamProvisioningProgress> => {
throw new Error('Team provisioning is not available in browser mode'); 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> => { cancelProvisioning: async (_runId: string): Promise<void> => {
throw new Error('Team provisioning is not available in browser mode'); 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 { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { renderLinkifiedText } from '@renderer/utils/linkifiedText'; import { renderLinkifiedText } from '@renderer/utils/linkifiedText';
@ -23,7 +24,7 @@ import { StepProgressBar } from './StepProgressBar';
import type { StepProgressBarStep } from './StepProgressBar'; import type { StepProgressBarStep } from './StepProgressBar';
import type { MemberLaunchDiagnosticsPayload } from '@renderer/utils/memberLaunchDiagnostics'; 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. */ /** Pre-built step definitions for the provisioning stepper. */
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({ const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
@ -69,6 +70,9 @@ export interface ProvisioningProgressBlockProps {
onDismiss?: (() => void) | null; onDismiss?: (() => void) | null;
/** ISO timestamp when provisioning started */ /** ISO timestamp when provisioning started */
startedAt?: string; 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 of the CLI process */
pid?: number; pid?: number;
/** CLI logs captured during launch */ /** CLI logs captured during launch */
@ -202,6 +206,49 @@ function formatMemberDiagnosticsCopy(
return JSON.stringify(items, null, 2); 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: { function buildProvisioningDiagnosticsCopy(input: {
title: string; title: string;
message?: string | null; message?: string | null;
@ -216,6 +263,8 @@ function buildProvisioningDiagnosticsCopy(input: {
cliLogsTail?: string; cliLogsTail?: string;
launchDiagnostics?: TeamLaunchDiagnosticItem[]; launchDiagnostics?: TeamLaunchDiagnosticItem[];
memberDiagnostics?: MemberLaunchDiagnosticsPayload[]; memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
launchFailureArtifact?: TeamLaunchFailureDiagnosticsBundle | null;
launchFailureArtifactError?: string | null;
}): string { }): string {
const payload = [ const payload = [
'# Team provisioning diagnostics', '# Team provisioning diagnostics',
@ -237,6 +286,9 @@ function buildProvisioningDiagnosticsCopy(input: {
'## Member launch snapshots', '## Member launch snapshots',
formatMemberDiagnosticsCopy(input.memberDiagnostics), formatMemberDiagnosticsCopy(input.memberDiagnostics),
'', '',
'## Launch failure artifact bundle',
formatLaunchFailureArtifactCopy(input.launchFailureArtifact, input.launchFailureArtifactError),
'',
'## Live output', '## Live output',
input.liveOutput?.trim() || '(empty)', input.liveOutput?.trim() || '(empty)',
'', '',
@ -262,6 +314,8 @@ export const ProvisioningProgressBlock = ({
successMessageSeverity = 'success', successMessageSeverity = 'success',
onDismiss, onDismiss,
startedAt, startedAt,
teamName,
runId,
pid, pid,
cliLogsTail, cliLogsTail,
assistantOutput, assistantOutput,
@ -279,39 +333,6 @@ export const ProvisioningProgressBlock = ({
const copyResetTimerRef = useRef<number | null>(null); const copyResetTimerRef = useRef<number | null>(null);
const isError = tone === 'error'; const isError = tone === 'error';
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); 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 = const visibleLaunchDiagnostics =
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ?? launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
[]; [];
@ -357,6 +378,32 @@ export const ProvisioningProgressBlock = ({
setDiagnosticsCopied(false); setDiagnosticsCopied(false);
return; 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 { try {
await navigator.clipboard.writeText(diagnosticsCopyText); await navigator.clipboard.writeText(diagnosticsCopyText);
} catch { } catch {

View file

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

View file

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

View file

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

View file

@ -991,7 +991,7 @@ export interface TeamLaunchRequest {
fastMode?: TeamFastMode; fastMode?: TeamFastMode;
/** When true, context window is limited to 200K tokens instead of the default. */ /** When true, context window is limited to 200K tokens instead of the default. */
limitContext?: boolean; 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; clearContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
skipPermissions?: boolean; skipPermissions?: boolean;
@ -1479,6 +1479,35 @@ export interface TeamLaunchDiagnosticItem {
observedAt: string; 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 { export interface TeamRuntimeState {
teamName: string; teamName: string;
isAlive: boolean; isAlive: boolean;

View file

@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
classifyLaunchFailureArtifact, classifyLaunchFailureArtifact,
extractLaunchBootstrapTransportBreadcrumb, extractLaunchBootstrapTransportBreadcrumb,
readTeamLaunchFailureDiagnosticsBundle,
redactLaunchFailureArtifactText, redactLaunchFailureArtifactText,
writeTeamLaunchFailureArtifactPack, writeTeamLaunchFailureArtifactPack,
} from '../../../../src/main/services/team/TeamLaunchFailureArtifactPack'; } from '../../../../src/main/services/team/TeamLaunchFailureArtifactPack';
@ -103,7 +104,10 @@ describe('TeamLaunchFailureArtifactPack', () => {
expect(manifest.artifactFiles).toContain('launch-state.json'); expect(manifest.artifactFiles).toContain('launch-state.json');
expect(manifest.progress.error).toContain('[REDACTED]'); 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(copiedLaunchState).toContain('[REDACTED_ANTHROPIC_API_KEY]');
expect(() => JSON.parse(copiedLaunchState)).not.toThrow(); expect(() => JSON.parse(copiedLaunchState)).not.toThrow();
expect(copiedLaunchState).toContain('"token":"[REDACTED]"'); expect(copiedLaunchState).toContain('"token":"[REDACTED]"');
@ -117,6 +121,29 @@ describe('TeamLaunchFailureArtifactPack', () => {
await fs.readFile(path.join(teamDir, 'launch-failure-artifacts', 'latest.json'), 'utf8') await fs.readFile(path.join(teamDir, 'launch-failure-artifacts', 'latest.json'), 'utf8')
) as { manifestPath: string }; ) as { manifestPath: string };
expect(latest.manifestPath).toBe(result.manifestPath); 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', () => { it('redacts common bearer and token-shaped secrets', () => {
@ -140,7 +167,8 @@ describe('TeamLaunchFailureArtifactPack', () => {
expect(classifyLaunchFailureArtifact(input).code).toBe('transport_rejected'); expect(classifyLaunchFailureArtifact(input).code).toBe('transport_rejected');
expect(extractLaunchBootstrapTransportBreadcrumb(input)).toMatchObject({ 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, submitRejected: true,
retryable: true, retryable: true,
noStdinWarning: true, noStdinWarning: true,

View file

@ -14062,7 +14062,7 @@ describe('TeamProvisioningService', () => {
).toBe('Questions (2): First question with extra spacing.'); ).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(); allowConsoleLogs();
const teamName = 'resume-skip-team'; const teamName = 'resume-skip-team';
const leadSessionId = 'lead-session-skip'; const leadSessionId = 'lead-session-skip';
@ -14114,7 +14114,7 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).not.toContain(leadSessionId); 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(); allowConsoleLogs();
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z')); vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z'));
@ -14178,7 +14178,7 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).not.toContain(leadSessionId); 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(); allowConsoleLogs();
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z')); vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z'));
@ -14244,7 +14244,7 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).not.toContain(leadSessionId); 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(); allowConsoleLogs();
const teamName = 'resume-keep-team'; const teamName = 'resume-keep-team';
const leadSessionId = 'lead-session-keep'; const leadSessionId = 'lead-session-keep';
@ -14291,11 +14291,11 @@ describe('TeamProvisioningService', () => {
); );
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toContain('--resume'); expect(launchArgs).not.toContain('--resume');
expect(launchArgs).toContain(leadSessionId); 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(); allowConsoleLogs();
const teamName = 'resume-backend-change-team'; const teamName = 'resume-backend-change-team';
const leadSessionId = 'lead-session-backend-change'; const leadSessionId = 'lead-session-backend-change';
@ -14348,11 +14348,11 @@ describe('TeamProvisioningService', () => {
const launchArgs = vi.mocked(spawnCli).mock.calls.at(-1)?.[1] as string[]; const launchArgs = vi.mocked(spawnCli).mock.calls.at(-1)?.[1] as string[];
expect(launchArgs).toBeTruthy(); expect(launchArgs).toBeTruthy();
expect(launchArgs).toContain('--resume'); expect(launchArgs).not.toContain('--resume');
expect(launchArgs).toContain(leadSessionId); 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(); allowConsoleLogs();
const teamName = 'resume-seed-session-team'; const teamName = 'resume-seed-session-team';
const leadSessionId = 'lead-session-seeded'; const leadSessionId = 'lead-session-seeded';
@ -14388,7 +14388,10 @@ describe('TeamProvisioningService', () => {
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {}); 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); await svc.cancelProvisioning(runId);
}); });