feat(team): capture launch failure artifacts
This commit is contained in:
parent
064f5b977d
commit
e3d0d56073
14 changed files with 424 additions and 302 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
|
|
@ -143,6 +144,7 @@ import {
|
|||
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamConfigReader } from '../services/team/TeamConfigReader';
|
||||
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
|
||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
|
|
@ -215,6 +217,7 @@ import type {
|
|||
TeamGetDataOptions,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProviderBackendId,
|
||||
|
|
@ -702,6 +705,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
||||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||||
ipcMain.handle(TEAM_LAUNCH_FAILURE_DIAGNOSTICS, handleLaunchFailureDiagnostics);
|
||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||
ipcMain.handle(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, handleGetOpenCodeRuntimeDeliveryStatus);
|
||||
|
|
@ -788,6 +792,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_CREATE);
|
||||
ipcMain.removeHandler(TEAM_LAUNCH);
|
||||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||||
ipcMain.removeHandler(TEAM_LAUNCH_FAILURE_DIAGNOSTICS);
|
||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS);
|
||||
|
|
@ -2381,6 +2386,21 @@ async function handleProvisioningStatus(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleLaunchFailureDiagnostics(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
runId: unknown
|
||||
): Promise<IpcResult<TeamLaunchFailureDiagnosticsBundle>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const validatedRunId = typeof runId === 'string' && runId.trim() ? runId.trim() : undefined;
|
||||
return wrapTeamHandler('launchFailureDiagnostics', () =>
|
||||
readTeamLaunchFailureDiagnosticsBundle(validatedTeamName.value!, validatedRunId)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCancelProvisioning(
|
||||
_event: IpcMainInvokeEvent,
|
||||
runId: unknown
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
MemberSpawnStatusEntry,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamLaunchDiagnosticItem,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamMember,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
|
|
@ -24,6 +25,7 @@ const LATEST_ARTIFACT_FILE = 'latest.json';
|
|||
const MAX_CLI_LOG_CHARS = 256_000;
|
||||
const MAX_TRACE_CHARS = 128_000;
|
||||
const MAX_COPIED_FILE_BYTES = 256 * 1024;
|
||||
const MAX_DIAGNOSTICS_COPY_FILE_BYTES = 128 * 1024;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
|
|
@ -332,6 +334,181 @@ async function readBoundedTextFile(sourcePath: string): Promise<{ text?: string;
|
|||
}
|
||||
}
|
||||
|
||||
async function readDiagnosticsCopyFile(
|
||||
label: string,
|
||||
sourcePath: string
|
||||
): Promise<TeamLaunchFailureDiagnosticsBundle['files'][number]> {
|
||||
const read = await readBoundedTextFile(sourcePath);
|
||||
if (read.text === undefined) {
|
||||
return {
|
||||
label,
|
||||
path: sourcePath,
|
||||
issue: read.issue ?? 'unreadable',
|
||||
};
|
||||
}
|
||||
|
||||
const text =
|
||||
read.text.length > MAX_DIAGNOSTICS_COPY_FILE_BYTES
|
||||
? `[truncated to last ${MAX_DIAGNOSTICS_COPY_FILE_BYTES} chars]\n${read.text.slice(
|
||||
read.text.length - MAX_DIAGNOSTICS_COPY_FILE_BYTES
|
||||
)}`
|
||||
: read.text;
|
||||
|
||||
return {
|
||||
label,
|
||||
path: sourcePath,
|
||||
content: redactLaunchFailureArtifactText(text).trimEnd(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string | undefined): JsonRecord | null {
|
||||
if (!text) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as JsonRecord)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : null;
|
||||
}
|
||||
|
||||
function resolveArtifactManifestPath(
|
||||
teamDir: string,
|
||||
latestJson: JsonRecord | null,
|
||||
runId?: string
|
||||
): { path?: string; issue?: string } {
|
||||
if (!latestJson) {
|
||||
return { issue: 'latest_json_unavailable' };
|
||||
}
|
||||
const latestRunId = getString(latestJson.runId);
|
||||
if (runId && latestRunId && latestRunId !== runId) {
|
||||
return { issue: `latest_run_mismatch:${latestRunId}` };
|
||||
}
|
||||
|
||||
const manifestPath = getString(latestJson.manifestPath);
|
||||
if (!manifestPath) {
|
||||
return { issue: 'manifest_path_missing' };
|
||||
}
|
||||
|
||||
try {
|
||||
assertPathWithin(path.join(teamDir, ARTIFACTS_DIR_NAME), manifestPath);
|
||||
} catch {
|
||||
return { issue: 'manifest_path_outside_artifacts_dir' };
|
||||
}
|
||||
return { path: manifestPath };
|
||||
}
|
||||
|
||||
export async function readTeamLaunchFailureDiagnosticsBundle(
|
||||
teamName: string,
|
||||
runId?: string
|
||||
): Promise<TeamLaunchFailureDiagnosticsBundle> {
|
||||
const teamDir = path.join(getTeamsBasePath(), teamName);
|
||||
const latestPath = path.join(teamDir, ARTIFACTS_DIR_NAME, LATEST_ARTIFACT_FILE);
|
||||
const latestFile = await readDiagnosticsCopyFile(
|
||||
'launch-failure-artifacts/latest.json',
|
||||
latestPath
|
||||
);
|
||||
const latestJson = parseJsonObject(latestFile.content);
|
||||
const resolvedManifest = resolveArtifactManifestPath(teamDir, latestJson, runId);
|
||||
const files: TeamLaunchFailureDiagnosticsBundle['files'] = [latestFile];
|
||||
|
||||
let manifestJson: JsonRecord | null = null;
|
||||
if (resolvedManifest.path) {
|
||||
const manifestFile = await readDiagnosticsCopyFile(
|
||||
'launch-failure-artifacts/manifest.json',
|
||||
resolvedManifest.path
|
||||
);
|
||||
files.push(manifestFile);
|
||||
manifestJson = parseJsonObject(manifestFile.content);
|
||||
} else {
|
||||
files.push({
|
||||
label: 'launch-failure-artifacts/manifest.json',
|
||||
path:
|
||||
getString(latestJson?.manifestPath) ??
|
||||
path.join(teamDir, ARTIFACTS_DIR_NAME, 'manifest.json'),
|
||||
issue: resolvedManifest.issue ?? 'manifest_unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
files.push(
|
||||
await readDiagnosticsCopyFile(
|
||||
'bootstrap-journal.jsonl',
|
||||
path.join(teamDir, 'bootstrap-journal.jsonl')
|
||||
),
|
||||
await readDiagnosticsCopyFile('launch-state.json', getTeamLaunchStatePath(teamName))
|
||||
);
|
||||
|
||||
const classification = getRecord(manifestJson?.classification);
|
||||
const bootstrapTransportBreadcrumb = getRecord(manifestJson?.bootstrapTransportBreadcrumb);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
...(runId
|
||||
? { runId }
|
||||
: getString(latestJson?.runId)
|
||||
? { runId: getString(latestJson?.runId) }
|
||||
: {}),
|
||||
latestPath,
|
||||
...(getString(latestJson?.directory)
|
||||
? { artifactDirectory: getString(latestJson?.directory) }
|
||||
: {}),
|
||||
...(resolvedManifest.path ? { manifestPath: resolvedManifest.path } : {}),
|
||||
classification: classification
|
||||
? {
|
||||
code: getString(classification.code),
|
||||
confidence:
|
||||
typeof classification.confidence === 'number' ? classification.confidence : undefined,
|
||||
evidence: Array.isArray(classification.evidence)
|
||||
? classification.evidence.filter((item): item is string => typeof item === 'string')
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
bootstrapTransportBreadcrumb: bootstrapTransportBreadcrumb
|
||||
? {
|
||||
lastTransportStage:
|
||||
typeof bootstrapTransportBreadcrumb.lastTransportStage === 'string'
|
||||
? bootstrapTransportBreadcrumb.lastTransportStage
|
||||
: bootstrapTransportBreadcrumb.lastTransportStage === null
|
||||
? null
|
||||
: undefined,
|
||||
submitRejected:
|
||||
typeof bootstrapTransportBreadcrumb.submitRejected === 'boolean'
|
||||
? bootstrapTransportBreadcrumb.submitRejected
|
||||
: undefined,
|
||||
retryable:
|
||||
typeof bootstrapTransportBreadcrumb.retryable === 'boolean'
|
||||
? bootstrapTransportBreadcrumb.retryable
|
||||
: bootstrapTransportBreadcrumb.retryable === null
|
||||
? null
|
||||
: undefined,
|
||||
noStdinWarning:
|
||||
typeof bootstrapTransportBreadcrumb.noStdinWarning === 'boolean'
|
||||
? bootstrapTransportBreadcrumb.noStdinWarning
|
||||
: undefined,
|
||||
bootstrapSubmitted:
|
||||
typeof bootstrapTransportBreadcrumb.bootstrapSubmitted === 'boolean'
|
||||
? bootstrapTransportBreadcrumb.bootstrapSubmitted
|
||||
: undefined,
|
||||
evidence: Array.isArray(bootstrapTransportBreadcrumb.evidence)
|
||||
? bootstrapTransportBreadcrumb.evidence.filter(
|
||||
(item): item is string => typeof item === 'string'
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function getKnownLaunchArtifactSourceFiles(teamName: string): CopiedArtifactFile[] {
|
||||
const bootstrapStatePath = getTeamBootstrapStatePath(teamName);
|
||||
const teamDir = path.dirname(bootstrapStatePath);
|
||||
|
|
|
|||
|
|
@ -3488,68 +3488,6 @@ function buildEffectiveTeamMemberSpecs(
|
|||
return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults));
|
||||
}
|
||||
|
||||
function shouldSkipResumeForProviderRuntimeChange(
|
||||
request: Pick<TeamLaunchRequest, 'providerId' | 'providerBackendId' | 'model'>,
|
||||
config: Record<string, unknown>,
|
||||
persistedProviderBackendId?: string | null
|
||||
): { skip: boolean; reason?: string } {
|
||||
const providerId = normalizeTeamMemberProviderId(request.providerId);
|
||||
if (providerId !== 'gemini' && providerId !== 'codex') {
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
const requestedBackendId =
|
||||
migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || null;
|
||||
const previousBackendId =
|
||||
migrateProviderBackendId(providerId, persistedProviderBackendId?.trim()) || null;
|
||||
if (requestedBackendId && previousBackendId && requestedBackendId !== previousBackendId) {
|
||||
return {
|
||||
skip: true,
|
||||
reason: `runtime backend changed (${previousBackendId} -> ${requestedBackendId})`,
|
||||
};
|
||||
}
|
||||
|
||||
const members = Array.isArray(config.members)
|
||||
? (config.members as Record<string, unknown>[])
|
||||
: [];
|
||||
const lead =
|
||||
members.find((member) => isLeadMember(member)) ??
|
||||
members.find((member) => {
|
||||
const name = typeof member?.name === 'string' ? member.name.trim().toLowerCase() : '';
|
||||
return name === 'team-lead';
|
||||
});
|
||||
if (!lead) {
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
const currentLeadProviderId =
|
||||
normalizeTeamMemberProviderId(
|
||||
typeof lead.providerId === 'string'
|
||||
? lead.providerId
|
||||
: typeof lead.provider === 'string'
|
||||
? lead.provider
|
||||
: providerId
|
||||
) ?? providerId;
|
||||
const requestedModel = request.model?.trim() || '';
|
||||
const currentLeadModel = typeof lead.model === 'string' ? lead.model.trim() : '';
|
||||
|
||||
if (currentLeadProviderId !== providerId) {
|
||||
return {
|
||||
skip: true,
|
||||
reason: `provider changed (${currentLeadProviderId} -> ${providerId})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedModel && currentLeadModel && requestedModel !== currentLeadModel) {
|
||||
return {
|
||||
skip: true,
|
||||
reason: `model changed (${currentLeadModel} -> ${requestedModel})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
||||
return members
|
||||
.map((member) => {
|
||||
|
|
@ -4637,8 +4575,10 @@ function buildDeterministicLaunchHydrationPrompt(
|
|||
const isSolo = members.length === 0;
|
||||
const projectName = path.basename(request.cwd);
|
||||
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
|
||||
const startupLabel = isResume ? 'resume/bootstrap' : 'launch/bootstrap';
|
||||
const headerModeLabel = isResume ? 'Deterministic resume' : 'Deterministic launch';
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n`
|
||||
? `\nOriginal user instructions to apply after ${isResume ? 'resume' : 'startup'} is stable:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
const hasOriginalUserPrompt = Boolean(request.prompt?.trim());
|
||||
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
|
||||
|
|
@ -4649,7 +4589,7 @@ function buildDeterministicLaunchHydrationPrompt(
|
|||
members,
|
||||
});
|
||||
const nextSteps = isSolo
|
||||
? `This reconnect/bootstrap step has already been completed deterministically by the runtime.
|
||||
? `This ${startupLabel} step has already been completed deterministically by the runtime.
|
||||
Do NOT call TeamCreate.
|
||||
Do NOT use Agent to spawn or restore teammates.
|
||||
Do NOT start implementation in this turn.
|
||||
|
|
@ -4659,7 +4599,7 @@ ${
|
|||
? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.'
|
||||
: 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
|
||||
}`
|
||||
: `This reconnect/bootstrap step has already been completed deterministically by the runtime.
|
||||
: `This ${startupLabel} step has already been completed deterministically by the runtime.
|
||||
Do NOT call TeamCreate.
|
||||
Do NOT use Agent to spawn or restore teammates.
|
||||
Do NOT repeat the launch summary.
|
||||
|
|
@ -4671,7 +4611,7 @@ ${
|
|||
}
|
||||
Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`;
|
||||
|
||||
return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||
return `${startLabel} [${headerModeLabel} | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||
|
||||
You are running headless in a non-interactive CLI session. Do not ask questions.
|
||||
You are "${leadName}", the team lead.
|
||||
|
|
@ -18419,8 +18359,8 @@ export class TeamProvisioningService {
|
|||
run,
|
||||
'configuring',
|
||||
run.deterministicBootstrap
|
||||
? 'CLI running — deterministic reconnect in progress'
|
||||
: 'CLI running — reconnecting with teammates'
|
||||
? 'CLI running - deterministic launch in progress'
|
||||
: 'CLI running - reconnecting with teammates'
|
||||
);
|
||||
run.onProgress(run.progress);
|
||||
}
|
||||
|
|
@ -19789,135 +19729,19 @@ export class TeamProvisioningService {
|
|||
providerId: request.providerId,
|
||||
members: expectedMemberSpecs,
|
||||
});
|
||||
// Extract leadSessionId for session resume on reconnect.
|
||||
// If a valid JSONL file exists for the previous session, we can resume it
|
||||
// so the lead retains full context of prior work.
|
||||
// When clearContext is true, skip resume entirely to start a fresh session.
|
||||
let previousSessionId: string | undefined;
|
||||
let skipResume = false;
|
||||
// Deterministic launch always sends --team-bootstrap-spec. The orchestrator
|
||||
// rejects combining that startup mode with --resume, so relaunch starts a
|
||||
// fresh lead runtime session and restores operational context from durable
|
||||
// team state instead of the previous transcript.
|
||||
if (request.clearContext) {
|
||||
skipResume = true;
|
||||
logger.info(
|
||||
`[${request.teamName}] clearContext requested — skipping session resume, starting fresh`
|
||||
`[${request.teamName}] clearContext requested - starting fresh deterministic bootstrap session`
|
||||
);
|
||||
} else {
|
||||
// Check persisted launch state: if the previous launch ended with no teammates
|
||||
// ever spawned (all in 'starting' state), resuming would reconnect the lead but
|
||||
// the CLI's deterministic bootstrap won't re-spawn dead teammates in reconnect
|
||||
// mode. Skip resume so the CLI creates a fresh session that fully bootstraps.
|
||||
const persistedLaunchState = await this.launchStateStore.read(request.teamName);
|
||||
if (persistedLaunchState) {
|
||||
const {
|
||||
expectedMembers: prevExpected,
|
||||
members: prevMembers,
|
||||
launchPhase,
|
||||
} = persistedLaunchState;
|
||||
const teammateWasNeverSpawned = (
|
||||
member:
|
||||
| {
|
||||
agentToolAccepted?: boolean;
|
||||
firstSpawnAcceptedAt?: string;
|
||||
runtimeAlive?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
}
|
||||
| undefined
|
||||
): boolean => {
|
||||
if (!member) return true;
|
||||
const hasAcceptedSpawn =
|
||||
member.agentToolAccepted === true ||
|
||||
(typeof member.firstSpawnAcceptedAt === 'string' &&
|
||||
member.firstSpawnAcceptedAt.trim().length > 0);
|
||||
return (
|
||||
!hasAcceptedSpawn &&
|
||||
member.runtimeAlive !== true &&
|
||||
member.bootstrapConfirmed !== true
|
||||
);
|
||||
};
|
||||
const updatedAtMs = Date.parse(persistedLaunchState.updatedAt);
|
||||
const activeLaunchLooksStale =
|
||||
launchPhase === 'active' &&
|
||||
(!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs >= MEMBER_LAUNCH_GRACE_MS);
|
||||
const launchOutcomeIsSettledOrStale = launchPhase !== 'active' || activeLaunchLooksStale;
|
||||
const hasPreviousExpectedTeammates = prevExpected.length > 0;
|
||||
const previousTeammates = prevExpected.map((name) => prevMembers[name]);
|
||||
const staleActiveLaunchHasNoLiveTeammates =
|
||||
activeLaunchLooksStale &&
|
||||
hasPreviousExpectedTeammates &&
|
||||
!previousTeammates.some(
|
||||
(member) => member?.runtimeAlive === true || member?.bootstrapConfirmed === true
|
||||
);
|
||||
const allTeammatesNeverSpawned =
|
||||
launchOutcomeIsSettledOrStale &&
|
||||
hasPreviousExpectedTeammates &&
|
||||
previousTeammates.every(teammateWasNeverSpawned);
|
||||
if (allTeammatesNeverSpawned || staleActiveLaunchHasNoLiveTeammates) {
|
||||
skipResume = true;
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous launch cannot be resumed safely - ` +
|
||||
`skipping session resume to allow full bootstrap`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!skipResume) {
|
||||
try {
|
||||
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
|
||||
const persistedTeamMeta = await this.teamMetaStore
|
||||
.getMeta(request.teamName)
|
||||
.catch(() => null);
|
||||
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(
|
||||
request,
|
||||
configParsed,
|
||||
persistedTeamMeta?.providerBackendId ?? null
|
||||
);
|
||||
if (resumeGuard.skip) {
|
||||
logger.info(
|
||||
`[${request.teamName}] Skipping session resume — ${resumeGuard.reason ?? 'runtime changed'}`
|
||||
);
|
||||
} else if (
|
||||
typeof configParsed.leadSessionId === 'string' &&
|
||||
configParsed.leadSessionId.trim().length > 0
|
||||
) {
|
||||
const candidateId = configParsed.leadSessionId.trim();
|
||||
const storedProjectPath =
|
||||
typeof configParsed.projectPath === 'string' &&
|
||||
configParsed.projectPath.trim().length > 0
|
||||
? configParsed.projectPath.trim()
|
||||
: null;
|
||||
|
||||
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
|
||||
// If the project path changed, the old session JSONL won't be found by the CLI
|
||||
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
|
||||
if (
|
||||
storedProjectPath &&
|
||||
path.resolve(storedProjectPath) !== path.resolve(request.cwd)
|
||||
) {
|
||||
logger.info(
|
||||
`[${request.teamName}] Project path changed: ${storedProjectPath} → ${request.cwd}. ` +
|
||||
`Skipping session resume — sessions are per-project.`
|
||||
);
|
||||
} else {
|
||||
const resumeProjectPath = storedProjectPath ?? request.cwd;
|
||||
const projectId = encodePath(resumeProjectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
|
||||
if (await this.pathExists(jsonlPath)) {
|
||||
previousSessionId = candidateId;
|
||||
logger.info(
|
||||
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.debug(
|
||||
`[${request.teamName}] Failed to extract leadSessionId from config for resume`
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
`[${request.teamName}] Starting fresh deterministic bootstrap session because ` +
|
||||
`--team-bootstrap-spec cannot be combined with --resume`
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
|
||||
|
|
@ -20117,7 +19941,7 @@ export class TeamProvisioningService {
|
|||
provisioningTraceLines: [],
|
||||
lastProvisioningTraceKey: null,
|
||||
provisioningOutputIndexByMessageId: new Map(),
|
||||
detectedSessionId: previousSessionId ?? null,
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
leadContextUsage: null,
|
||||
authFailureRetried: false,
|
||||
|
|
@ -20188,7 +20012,7 @@ export class TeamProvisioningService {
|
|||
request,
|
||||
effectiveMemberSpecs,
|
||||
existingTasks,
|
||||
Boolean(previousSessionId)
|
||||
false
|
||||
);
|
||||
const promptSize = getPromptSizeSummary(prompt);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
|
|
@ -20289,12 +20113,6 @@ export class TeamProvisioningService {
|
|||
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
|
||||
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
||||
];
|
||||
if (previousSessionId) {
|
||||
launchArgs.push('--resume', previousSessionId);
|
||||
logger.info(
|
||||
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
|
||||
);
|
||||
}
|
||||
const launchModelArg = getLaunchModelArg(
|
||||
resolveTeamProviderId(request.providerId),
|
||||
request.model,
|
||||
|
|
@ -20344,8 +20162,8 @@ export class TeamProvisioningService {
|
|||
expectedMembersCount: effectiveMemberSpecs.length,
|
||||
launchIdentity,
|
||||
});
|
||||
// --resume is added above when a valid previous session JSONL exists.
|
||||
// Without it, CLI creates a fresh session ID automatically.
|
||||
// Deterministic bootstrap launches fresh because --team-bootstrap-spec and
|
||||
// --resume are not a supported orchestrator combination.
|
||||
emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn');
|
||||
await this.teamMetaStore.writeMeta(request.teamName, {
|
||||
displayName: syntheticRequest.displayName,
|
||||
|
|
@ -20417,8 +20235,7 @@ export class TeamProvisioningService {
|
|||
throw error;
|
||||
}
|
||||
|
||||
const resumeHint = previousSessionId ? ' (resuming previous session)' : '';
|
||||
updateProgress(run, 'spawning', `Starting Claude CLI process for team launch${resumeHint}`, {
|
||||
updateProgress(run, 'spawning', 'Starting Claude CLI process for team launch', {
|
||||
pid: child.pid ?? undefined,
|
||||
warnings: mergeProvisioningWarnings(run.progress.warnings, runtimeWarning),
|
||||
});
|
||||
|
|
@ -20444,7 +20261,7 @@ export class TeamProvisioningService {
|
|||
// For launch, skip the filesystem monitor — files (config, inboxes, tasks)
|
||||
// already exist from the previous run and would trigger immediate false
|
||||
// completion on the first poll. Rely on stream-json result.success instead.
|
||||
updateProgress(run, 'configuring', 'CLI running — deterministic reconnect in progress');
|
||||
updateProgress(run, 'configuring', 'CLI running - deterministic launch in progress');
|
||||
run.onProgress(run.progress);
|
||||
|
||||
run.timeoutHandle = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ import {
|
|||
TEAM_INITIALIZE_GIT_REPOSITORY,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
|
|
@ -320,6 +321,7 @@ import type {
|
|||
TeamGetDataOptions,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
|
|
@ -951,6 +953,13 @@ const electronAPI: ElectronAPI = {
|
|||
getProvisioningStatus: async (runId: string) => {
|
||||
return invokeIpcWithResult<TeamProvisioningProgress>(TEAM_PROVISIONING_STATUS, runId);
|
||||
},
|
||||
getLaunchFailureDiagnostics: async (teamName: string, runId?: string) => {
|
||||
return invokeIpcWithResult<TeamLaunchFailureDiagnosticsBundle>(
|
||||
TEAM_LAUNCH_FAILURE_DIAGNOSTICS,
|
||||
teamName,
|
||||
runId
|
||||
);
|
||||
},
|
||||
cancelProvisioning: async (runId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_CANCEL_PROVISIONING, runId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamGetDataOptions,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
|
|
@ -819,6 +820,12 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getProvisioningStatus: async (_runId: string): Promise<TeamProvisioningProgress> => {
|
||||
throw new Error('Team provisioning is not available in browser mode');
|
||||
},
|
||||
getLaunchFailureDiagnostics: async (
|
||||
_teamName: string,
|
||||
_runId?: string
|
||||
): Promise<TeamLaunchFailureDiagnosticsBundle> => {
|
||||
throw new Error('Launch failure diagnostics are not available in browser mode');
|
||||
},
|
||||
cancelProvisioning: async (_runId: string): Promise<void> => {
|
||||
throw new Error('Team provisioning is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { renderLinkifiedText } from '@renderer/utils/linkifiedText';
|
||||
|
|
@ -23,7 +24,7 @@ import { StepProgressBar } from './StepProgressBar';
|
|||
|
||||
import type { StepProgressBarStep } from './StepProgressBar';
|
||||
import type { MemberLaunchDiagnosticsPayload } from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import type { TeamLaunchDiagnosticItem } from '@shared/types';
|
||||
import type { TeamLaunchDiagnosticItem, TeamLaunchFailureDiagnosticsBundle } from '@shared/types';
|
||||
|
||||
/** Pre-built step definitions for the provisioning stepper. */
|
||||
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
|
||||
|
|
@ -69,6 +70,9 @@ export interface ProvisioningProgressBlockProps {
|
|||
onDismiss?: (() => void) | null;
|
||||
/** ISO timestamp when provisioning started */
|
||||
startedAt?: string;
|
||||
/** Team/run identity used to enrich copied launch diagnostics with artifact pack data. */
|
||||
teamName?: string;
|
||||
runId?: string;
|
||||
/** PID of the CLI process */
|
||||
pid?: number;
|
||||
/** CLI logs captured during launch */
|
||||
|
|
@ -202,6 +206,49 @@ function formatMemberDiagnosticsCopy(
|
|||
return JSON.stringify(items, null, 2);
|
||||
}
|
||||
|
||||
function formatLaunchFailureArtifactCopy(
|
||||
bundle: TeamLaunchFailureDiagnosticsBundle | null | undefined,
|
||||
error?: string | null
|
||||
): string {
|
||||
if (error) {
|
||||
return `Failed to read launch failure artifact bundle: ${error}`;
|
||||
}
|
||||
if (!bundle) {
|
||||
return '(none)';
|
||||
}
|
||||
|
||||
const parts = [
|
||||
`teamName: ${bundle.teamName}`,
|
||||
`runId: ${formatOptionalValue(bundle.runId)}`,
|
||||
`latestPath: ${bundle.latestPath}`,
|
||||
`artifactDirectory: ${formatOptionalValue(bundle.artifactDirectory)}`,
|
||||
`manifestPath: ${formatOptionalValue(bundle.manifestPath)}`,
|
||||
'',
|
||||
'classification:',
|
||||
bundle.classification ? JSON.stringify(bundle.classification, null, 2) : '(none)',
|
||||
'',
|
||||
'bootstrapTransportBreadcrumb:',
|
||||
bundle.bootstrapTransportBreadcrumb
|
||||
? JSON.stringify(bundle.bootstrapTransportBreadcrumb, null, 2)
|
||||
: '(none)',
|
||||
'',
|
||||
'files:',
|
||||
];
|
||||
|
||||
for (const file of bundle.files) {
|
||||
parts.push(
|
||||
'',
|
||||
`--- ${file.label}`,
|
||||
`path: ${file.path}`,
|
||||
file.issue ? `issue: ${file.issue}` : 'issue: (none)',
|
||||
'content:',
|
||||
file.content?.trim() || '(empty)'
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function buildProvisioningDiagnosticsCopy(input: {
|
||||
title: string;
|
||||
message?: string | null;
|
||||
|
|
@ -216,6 +263,8 @@ function buildProvisioningDiagnosticsCopy(input: {
|
|||
cliLogsTail?: string;
|
||||
launchDiagnostics?: TeamLaunchDiagnosticItem[];
|
||||
memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
|
||||
launchFailureArtifact?: TeamLaunchFailureDiagnosticsBundle | null;
|
||||
launchFailureArtifactError?: string | null;
|
||||
}): string {
|
||||
const payload = [
|
||||
'# Team provisioning diagnostics',
|
||||
|
|
@ -237,6 +286,9 @@ function buildProvisioningDiagnosticsCopy(input: {
|
|||
'## Member launch snapshots',
|
||||
formatMemberDiagnosticsCopy(input.memberDiagnostics),
|
||||
'',
|
||||
'## Launch failure artifact bundle',
|
||||
formatLaunchFailureArtifactCopy(input.launchFailureArtifact, input.launchFailureArtifactError),
|
||||
'',
|
||||
'## Live output',
|
||||
input.liveOutput?.trim() || '(empty)',
|
||||
'',
|
||||
|
|
@ -262,6 +314,8 @@ export const ProvisioningProgressBlock = ({
|
|||
successMessageSeverity = 'success',
|
||||
onDismiss,
|
||||
startedAt,
|
||||
teamName,
|
||||
runId,
|
||||
pid,
|
||||
cliLogsTail,
|
||||
assistantOutput,
|
||||
|
|
@ -279,39 +333,6 @@ export const ProvisioningProgressBlock = ({
|
|||
const copyResetTimerRef = useRef<number | null>(null);
|
||||
const isError = tone === 'error';
|
||||
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||
const diagnosticsCopyText = useMemo(
|
||||
() =>
|
||||
buildProvisioningDiagnosticsCopy({
|
||||
title,
|
||||
message,
|
||||
messageSeverity,
|
||||
tone,
|
||||
startedAt,
|
||||
elapsed,
|
||||
pid,
|
||||
currentStepIndex,
|
||||
errorStepIndex,
|
||||
liveOutput: displayAssistantOutput,
|
||||
cliLogsTail,
|
||||
launchDiagnostics,
|
||||
memberDiagnostics,
|
||||
}),
|
||||
[
|
||||
title,
|
||||
message,
|
||||
messageSeverity,
|
||||
tone,
|
||||
startedAt,
|
||||
elapsed,
|
||||
pid,
|
||||
currentStepIndex,
|
||||
errorStepIndex,
|
||||
displayAssistantOutput,
|
||||
cliLogsTail,
|
||||
launchDiagnostics,
|
||||
memberDiagnostics,
|
||||
]
|
||||
);
|
||||
const visibleLaunchDiagnostics =
|
||||
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
|
||||
[];
|
||||
|
|
@ -357,6 +378,32 @@ export const ProvisioningProgressBlock = ({
|
|||
setDiagnosticsCopied(false);
|
||||
return;
|
||||
}
|
||||
let launchFailureArtifact: TeamLaunchFailureDiagnosticsBundle | null = null;
|
||||
let launchFailureArtifactError: string | null = null;
|
||||
if (teamName) {
|
||||
try {
|
||||
launchFailureArtifact = await api.teams.getLaunchFailureDiagnostics(teamName, runId);
|
||||
} catch (error) {
|
||||
launchFailureArtifactError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
const diagnosticsCopyText = buildProvisioningDiagnosticsCopy({
|
||||
title,
|
||||
message,
|
||||
messageSeverity,
|
||||
tone,
|
||||
startedAt,
|
||||
elapsed,
|
||||
pid,
|
||||
currentStepIndex,
|
||||
errorStepIndex,
|
||||
liveOutput: displayAssistantOutput,
|
||||
cliLogsTail,
|
||||
launchDiagnostics,
|
||||
memberDiagnostics,
|
||||
launchFailureArtifact,
|
||||
launchFailureArtifactError,
|
||||
});
|
||||
try {
|
||||
await navigator.clipboard.writeText(diagnosticsCopyText);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import {
|
|||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -82,7 +81,6 @@ import {
|
|||
ChevronRight,
|
||||
Info,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -452,7 +450,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [limitContext, setLimitContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastLimitContext') === 'true'
|
||||
);
|
||||
const [clearContext, setClearContext] = useState(false);
|
||||
const [conflictDismissed, setConflictDismissed] = useState(false);
|
||||
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
|
|
@ -715,7 +712,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setClearContext(false);
|
||||
setConflictDismissed(false);
|
||||
setMembersDrafts([]);
|
||||
setSyncModelsWithLead(false);
|
||||
|
|
@ -1811,7 +1807,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
} else if (selectedProviderId === 'codex') {
|
||||
args.push(...buildCodexFastModeArgs(codexFastModeResolution?.resolvedFastMode));
|
||||
}
|
||||
if (!clearContext) args.push('--resume', '<previous>');
|
||||
return args;
|
||||
}, [
|
||||
anthropicFastModeResolution?.resolvedFastMode,
|
||||
|
|
@ -1823,7 +1818,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedEffort,
|
||||
selectedProviderId,
|
||||
clearContext,
|
||||
runtimeProviderStatusById,
|
||||
]);
|
||||
|
||||
|
|
@ -1854,7 +1848,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
summary.push('Anthropic limited to 200K context');
|
||||
}
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
if (clearContext) summary.push('Fresh session');
|
||||
summary.push('Fresh lead session');
|
||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||
return summary;
|
||||
|
|
@ -1869,7 +1863,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
anthropicProviderFastModeDefault,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
skipPermissions,
|
||||
clearContext,
|
||||
worktreeEnabled,
|
||||
worktreeName,
|
||||
customArgs,
|
||||
|
|
@ -2117,7 +2110,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
? selectedFastMode
|
||||
: undefined,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
clearContext: clearContext || undefined,
|
||||
skipPermissions,
|
||||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||
extraCliArgs: customArgs.trim() || undefined,
|
||||
|
|
@ -2680,39 +2672,22 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="clear-context"
|
||||
checked={clearContext}
|
||||
onCheckedChange={(checked) => setClearContext(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="clear-context"
|
||||
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
|
||||
>
|
||||
<RotateCcw className="size-3 shrink-0" />
|
||||
Clear context (fresh session)
|
||||
</Label>
|
||||
</div>
|
||||
{clearContext && (
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>
|
||||
The team lead will start a new session without resuming previous context.
|
||||
All accumulated session memory and conversation history will not be
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>
|
||||
Team relaunch starts a fresh lead session. Durable team state, task board,
|
||||
and member configuration are rehydrated into the launch prompt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdvancedCliSection
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import type {
|
|||
TeamGetDataOptions,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
|
|
@ -499,6 +500,10 @@ export interface TeamsAPI {
|
|||
createInitialGitCommit: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
|
||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
getLaunchFailureDiagnostics: (
|
||||
teamName: string,
|
||||
runId?: string
|
||||
) => Promise<TeamLaunchFailureDiagnosticsBundle>;
|
||||
cancelProvisioning: (runId: string) => Promise<void>;
|
||||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||
getOpenCodeRuntimeDeliveryStatus: (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue