fix(team): surface stalled OpenCode bootstrap lanes

This commit is contained in:
777genius 2026-05-03 10:32:37 +03:00
parent 1339629da2
commit 9421fad08d
21 changed files with 805 additions and 24 deletions

View file

@ -72,6 +72,12 @@ export interface TeamGraphData extends TeamViewSnapshot {
messageFeed: InboxMessage[]; messageFeed: InboxMessage[];
} }
function toGraphLaunchVisualState(
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
): GraphNode['launchVisualState'] {
return visualState === 'bootstrap_stalled' ? 'runtime_pending' : (visualState ?? undefined);
}
export class TeamGraphAdapter { export class TeamGraphAdapter {
// ─── ES #private fields ────────────────────────────────────────────────── // ─── ES #private fields ──────────────────────────────────────────────────
#lastTeamName = ''; #lastTeamName = '';
@ -430,6 +436,7 @@ export class TeamGraphAdapter {
spawnLaunchState: undefined, spawnLaunchState: undefined,
spawnLivenessSource: undefined, spawnLivenessSource: undefined,
spawnRuntimeAlive: undefined, spawnRuntimeAlive: undefined,
spawnBootstrapStalled: undefined,
runtimeAdvisory: leadMember.runtimeAdvisory, runtimeAdvisory: leadMember.runtimeAdvisory,
isLaunchSettling: false, isLaunchSettling: false,
isTeamAlive: data.isAlive, isTeamAlive: data.isAlive,
@ -462,7 +469,7 @@ export class TeamGraphAdapter {
leadMember?.model, leadMember?.model,
leadMember?.effort leadMember?.effort
), ),
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined, launchVisualState: toGraphLaunchVisualState(leadLaunchPresentation?.launchVisualState),
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined, launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: leadMember avatarUrl: leadMember
@ -538,6 +545,7 @@ export class TeamGraphAdapter {
spawnLaunchState: spawn?.launchState, spawnLaunchState: spawn?.launchState,
spawnLivenessSource: spawn?.livenessSource, spawnLivenessSource: spawn?.livenessSource,
spawnRuntimeAlive: spawn?.runtimeAlive, spawnRuntimeAlive: spawn?.runtimeAlive,
spawnBootstrapStalled: spawn?.bootstrapStalled,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
isTeamAlive: data.isAlive, isTeamAlive: data.isAlive,
@ -562,7 +570,7 @@ export class TeamGraphAdapter {
), ),
spawnStatus: isTeamVisualOnline ? spawn?.status : undefined, spawnStatus: isTeamVisualOnline ? spawn?.status : undefined,
launchVisualState: isTeamVisualOnline launchVisualState: isTeamVisualOnline
? (launchPresentation.launchVisualState ?? undefined) ? toGraphLaunchVisualState(launchPresentation.launchVisualState)
: undefined, : undefined,
launchStatusLabel: isTeamVisualOnline launchStatusLabel: isTeamVisualOnline
? (launchPresentation.launchStatusLabel ?? undefined) ? (launchPresentation.launchStatusLabel ?? undefined)

View file

@ -325,6 +325,7 @@ const MemberPopoverContent = ({
spawnLaunchState: spawnEntry?.launchState, spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource, spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false, isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
isTeamAlive: teamData?.isAlive, isTeamAlive: teamData?.isAlive,

View file

@ -47,6 +47,7 @@ export interface MixedSecondaryLaneMemberStateInput {
pidSource?: TeamAgentRuntimePidSource; pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string; runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
bootstrapStalled?: boolean;
diagnostics?: string[]; diagnostics?: string[];
} | null; } | null;
pendingReason?: string; pendingReason?: string;
@ -96,6 +97,7 @@ function buildDiagnostics(
| 'hardFailureReason' | 'hardFailureReason'
| 'sources' | 'sources'
| 'pendingPermissionRequestIds' | 'pendingPermissionRequestIds'
| 'bootstrapStalled'
> >
): string[] { ): string[] {
const diagnostics: string[] = []; const diagnostics: string[] = [];
@ -104,6 +106,8 @@ function buildDiagnostics(
if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received'); if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received');
if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) {
diagnostics.push('waiting for permission approval'); diagnostics.push('waiting for permission approval');
} else if (member.bootstrapStalled) {
diagnostics.push('opencode_bootstrap_stalled');
} else if (member.runtimeAlive && !member.bootstrapConfirmed) { } else if (member.runtimeAlive && !member.bootstrapConfirmed) {
diagnostics.push('waiting for teammate check-in'); diagnostics.push('waiting for teammate check-in');
} }
@ -268,6 +272,15 @@ function createSecondaryLaneMemberState(
pidSource: evidence?.pidSource, pidSource: evidence?.pidSource,
runtimeDiagnostic: evidence?.runtimeDiagnostic, runtimeDiagnostic: evidence?.runtimeDiagnostic,
runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity, runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
bootstrapStalled:
providerId === 'opencode' &&
evidence?.bootstrapStalled === true &&
launchState === 'runtime_pending_bootstrap' &&
strongRuntimeAlive &&
evidence.bootstrapConfirmed !== true &&
hardFailure !== true
? true
: undefined,
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined, firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined, lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined, runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,

View file

@ -46,6 +46,7 @@ type RuntimeMemberSpawnState = Pick<
| 'livenessKind' | 'livenessKind'
| 'runtimeDiagnostic' | 'runtimeDiagnostic'
| 'runtimeDiagnosticSeverity' | 'runtimeDiagnosticSeverity'
| 'bootstrapStalled'
| 'livenessLastCheckedAt' | 'livenessLastCheckedAt'
| 'firstSpawnAcceptedAt' | 'firstSpawnAcceptedAt'
| 'lastHeartbeatAt' | 'lastHeartbeatAt'
@ -100,6 +101,47 @@ function preservesStrongRuntimeAlive(
); );
} }
function isOpenCodeSecondaryBootstrapPending(
member: Pick<
PersistedTeamLaunchMemberState,
| 'providerId'
| 'laneKind'
| 'laneOwnerProviderId'
| 'launchState'
| 'bootstrapConfirmed'
| 'hardFailure'
>
): boolean {
return (
member.providerId === 'opencode' &&
member.laneKind === 'secondary' &&
member.laneOwnerProviderId === 'opencode' &&
member.launchState === 'runtime_pending_bootstrap' &&
member.bootstrapConfirmed !== true &&
member.hardFailure !== true
);
}
function isPersistedBootstrapStalled(
member: Pick<
PersistedTeamLaunchMemberState,
| 'providerId'
| 'laneKind'
| 'laneOwnerProviderId'
| 'launchState'
| 'runtimeAlive'
| 'bootstrapConfirmed'
| 'hardFailure'
| 'bootstrapStalled'
>
): boolean {
return (
member.bootstrapStalled === true &&
isOpenCodeSecondaryBootstrapPending(member) &&
member.runtimeAlive === true
);
}
function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined { function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined {
return value === 'lead_process' || return value === 'lead_process' ||
value === 'tmux_pane' || value === 'tmux_pane' ||
@ -198,6 +240,7 @@ function buildDiagnostics(
| 'skipReason' | 'skipReason'
| 'sources' | 'sources'
| 'pendingPermissionRequestIds' | 'pendingPermissionRequestIds'
| 'bootstrapStalled'
> >
): string[] { ): string[] {
const diagnostics: string[] = []; const diagnostics: string[] = [];
@ -206,6 +249,8 @@ function buildDiagnostics(
if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received'); if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received');
if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) {
diagnostics.push('waiting for permission approval'); diagnostics.push('waiting for permission approval');
} else if (member.bootstrapStalled) {
diagnostics.push('opencode_bootstrap_stalled');
} else if (member.runtimeAlive && !member.bootstrapConfirmed) { } else if (member.runtimeAlive && !member.bootstrapConfirmed) {
diagnostics.push('waiting for teammate check-in'); diagnostics.push('waiting for teammate check-in');
} }
@ -545,6 +590,7 @@ function normalizePersistedMemberState(
pidSource: normalizePidSource(parsed.pidSource), pidSource: normalizePidSource(parsed.pidSource),
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic), runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity), runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
bootstrapStalled: toBoolean(parsed.bootstrapStalled),
runtimeLastSeenAt: normalizeOptionalString(parsed.runtimeLastSeenAt), runtimeLastSeenAt: normalizeOptionalString(parsed.runtimeLastSeenAt),
firstSpawnAcceptedAt: firstSpawnAcceptedAt:
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined, typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
@ -571,6 +617,9 @@ function normalizePersistedMemberState(
? parsed.launchState ? parsed.launchState
: deriveMemberLaunchState(next); : deriveMemberLaunchState(next);
next.launchState = launchState; next.launchState = launchState;
if (!isPersistedBootstrapStalled(next)) {
next.bootstrapStalled = undefined;
}
next.diagnostics = next.diagnostics?.length ? next.diagnostics : buildDiagnostics(next); next.diagnostics = next.diagnostics?.length ? next.diagnostics : buildDiagnostics(next);
return next; return next;
} }
@ -714,6 +763,7 @@ export function snapshotFromRuntimeMemberStatuses(params: {
livenessKind: runtime?.livenessKind, livenessKind: runtime?.livenessKind,
runtimeDiagnostic: runtime?.runtimeDiagnostic, runtimeDiagnostic: runtime?.runtimeDiagnostic,
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity, runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
bootstrapStalled: runtime?.bootstrapStalled === true,
runtimeLastSeenAt: runtime?.livenessLastCheckedAt, runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
lastHeartbeatAt: runtime?.lastHeartbeatAt, lastHeartbeatAt: runtime?.lastHeartbeatAt,
@ -723,6 +773,9 @@ export function snapshotFromRuntimeMemberStatuses(params: {
diagnostics: undefined, diagnostics: undefined,
}; };
entry.launchState = deriveMemberLaunchState(entry); entry.launchState = deriveMemberLaunchState(entry);
if (!isPersistedBootstrapStalled(entry)) {
entry.bootstrapStalled = undefined;
}
entry.diagnostics = buildDiagnostics(entry); entry.diagnostics = buildDiagnostics(entry);
members[name] = entry; members[name] = entry;
} }
@ -756,6 +809,8 @@ export function snapshotToMemberSpawnStatuses(
const skippedForLaunch = const skippedForLaunch =
entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true; entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true;
const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(entry); const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(entry);
const openCodeBootstrapPending = isOpenCodeSecondaryBootstrapPending(entry);
const bootstrapStalled = isPersistedBootstrapStalled(entry);
if (entry.launchState === 'failed_to_start') { if (entry.launchState === 'failed_to_start') {
status = 'error'; status = 'error';
} else if (entry.launchState === 'skipped_for_launch') { } else if (entry.launchState === 'skipped_for_launch') {
@ -767,8 +822,10 @@ export function snapshotToMemberSpawnStatuses(
entry.launchState === 'runtime_pending_permission' || entry.launchState === 'runtime_pending_permission' ||
entry.launchState === 'runtime_pending_bootstrap' entry.launchState === 'runtime_pending_bootstrap'
) { ) {
status = runtimeAlive ? 'online' : 'waiting'; status =
livenessSource = runtimeAlive ? 'process' : undefined; runtimeAlive && !openCodeBootstrapPending && !bootstrapStalled ? 'online' : 'waiting';
livenessSource =
runtimeAlive && !openCodeBootstrapPending && !bootstrapStalled ? 'process' : undefined;
} else { } else {
status = entry.agentToolAccepted ? 'waiting' : 'spawning'; status = entry.agentToolAccepted ? 'waiting' : 'spawning';
} }
@ -789,6 +846,7 @@ export function snapshotToMemberSpawnStatuses(
livenessKind: entry.livenessKind, livenessKind: entry.livenessKind,
runtimeDiagnostic: entry.runtimeDiagnostic, runtimeDiagnostic: entry.runtimeDiagnostic,
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity, runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
bootstrapStalled,
livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt, livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt,
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
lastHeartbeatAt: entry.lastHeartbeatAt, lastHeartbeatAt: entry.lastHeartbeatAt,

View file

@ -4075,6 +4075,18 @@ function buildLaunchDiagnosticsFromRun(
}); });
continue; continue;
} }
if (entry.bootstrapStalled === true) {
items.push({
id: `${memberName}:bootstrap_stalled`,
memberName,
severity: 'warning',
code: 'bootstrap_stalled',
label: `${memberName} - bootstrap stalled`,
detail: entry.runtimeDiagnostic,
observedAt,
});
continue;
}
if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) { if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) {
items.push({ items.push({
id: `${memberName}:process_table_unavailable`, id: `${memberName}:process_table_unavailable`,
@ -9283,6 +9295,7 @@ export class TeamProvisioningService {
runtimeAlive: true, runtimeAlive: true,
bootstrapConfirmed: true, bootstrapConfirmed: true,
hardFailure: false, hardFailure: false,
bootstrapStalled: undefined,
runtimePid, runtimePid,
runtimeRunId: input.runId, runtimeRunId: input.runId,
runtimeSessionId: input.runtimeSessionId, runtimeSessionId: input.runtimeSessionId,
@ -9432,6 +9445,7 @@ export class TeamProvisioningService {
runtimeAlive: true, runtimeAlive: true,
bootstrapConfirmed: true, bootstrapConfirmed: true,
hardFailure: false, hardFailure: false,
bootstrapStalled: undefined,
pendingPermissionRequestIds: undefined, pendingPermissionRequestIds: undefined,
firstSpawnAcceptedAt: previousStatus.firstSpawnAcceptedAt ?? input.observedAt, firstSpawnAcceptedAt: previousStatus.firstSpawnAcceptedAt ?? input.observedAt,
lastHeartbeatAt: input.observedAt, lastHeartbeatAt: input.observedAt,
@ -10247,6 +10261,7 @@ export class TeamProvisioningService {
next.runtimeAlive = false; next.runtimeAlive = false;
next.bootstrapConfirmed = false; next.bootstrapConfirmed = false;
next.hardFailure = false; next.hardFailure = false;
next.bootstrapStalled = undefined;
next.error = undefined; next.error = undefined;
next.hardFailureReason = undefined; next.hardFailureReason = undefined;
next.livenessSource = undefined; next.livenessSource = undefined;
@ -10265,6 +10280,7 @@ export class TeamProvisioningService {
next.runtimeAlive = false; next.runtimeAlive = false;
next.bootstrapConfirmed = false; next.bootstrapConfirmed = false;
next.hardFailure = false; next.hardFailure = false;
next.bootstrapStalled = undefined;
next.error = undefined; next.error = undefined;
next.hardFailureReason = undefined; next.hardFailureReason = undefined;
next.livenessSource = undefined; next.livenessSource = undefined;
@ -10294,6 +10310,7 @@ export class TeamProvisioningService {
: prev.lastHeartbeatAt; : prev.lastHeartbeatAt;
} }
next.hardFailure = false; next.hardFailure = false;
next.bootstrapStalled = undefined;
next.error = undefined; next.error = undefined;
next.hardFailureReason = undefined; next.hardFailureReason = undefined;
next.launchState = deriveMemberLaunchState(next); next.launchState = deriveMemberLaunchState(next);
@ -10303,6 +10320,7 @@ export class TeamProvisioningService {
next.skippedAt = undefined; next.skippedAt = undefined;
next.error = error; next.error = error;
next.hardFailure = true; next.hardFailure = true;
next.bootstrapStalled = undefined;
next.hardFailureReason = error; next.hardFailureReason = error;
next.launchState = 'failed_to_start'; next.launchState = 'failed_to_start';
} else if (status === 'skipped') { } else if (status === 'skipped') {
@ -10314,6 +10332,7 @@ export class TeamProvisioningService {
next.runtimeAlive = false; next.runtimeAlive = false;
next.bootstrapConfirmed = false; next.bootstrapConfirmed = false;
next.hardFailure = false; next.hardFailure = false;
next.bootstrapStalled = undefined;
next.error = undefined; next.error = undefined;
next.hardFailureReason = undefined; next.hardFailureReason = undefined;
next.livenessSource = undefined; next.livenessSource = undefined;
@ -10357,6 +10376,7 @@ export class TeamProvisioningService {
prev.livenessKind === next.livenessKind && prev.livenessKind === next.livenessKind &&
prev.runtimeDiagnostic === next.runtimeDiagnostic && prev.runtimeDiagnostic === next.runtimeDiagnostic &&
prev.runtimeDiagnosticSeverity === next.runtimeDiagnosticSeverity && prev.runtimeDiagnosticSeverity === next.runtimeDiagnosticSeverity &&
prev.bootstrapStalled === next.bootstrapStalled &&
prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt && prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt &&
prev.lastHeartbeatAt === next.lastHeartbeatAt prev.lastHeartbeatAt === next.lastHeartbeatAt
) { ) {
@ -10440,6 +10460,7 @@ export class TeamProvisioningService {
runtimeAlive: prev.runtimeAlive === true, runtimeAlive: prev.runtimeAlive === true,
bootstrapConfirmed: true, bootstrapConfirmed: true,
hardFailure: false, hardFailure: false,
bootstrapStalled: undefined,
error: undefined, error: undefined,
hardFailureReason: undefined, hardFailureReason: undefined,
livenessSource: prev.livenessSource ?? 'process', livenessSource: prev.livenessSource ?? 'process',
@ -10460,6 +10481,7 @@ export class TeamProvisioningService {
prev.runtimeAlive === next.runtimeAlive && prev.runtimeAlive === next.runtimeAlive &&
prev.bootstrapConfirmed === next.bootstrapConfirmed && prev.bootstrapConfirmed === next.bootstrapConfirmed &&
prev.hardFailure === next.hardFailure && prev.hardFailure === next.hardFailure &&
prev.bootstrapStalled === next.bootstrapStalled &&
prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt && prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt &&
prev.lastHeartbeatAt === next.lastHeartbeatAt prev.lastHeartbeatAt === next.lastHeartbeatAt
) { ) {
@ -10493,7 +10515,10 @@ export class TeamProvisioningService {
}> { }> {
const readPersistedStatuses = async (resolvedRunId: string | null) => { const readPersistedStatuses = async (resolvedRunId: string | null) => {
const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName); const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName);
const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, {
openCodeSecondaryBootstrapPendingMembers:
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
});
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
const summary = expectedMembers const summary = expectedMembers
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses) ? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
@ -10605,7 +10630,11 @@ export class TeamProvisioningService {
const launchSnapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers); const launchSnapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers);
const statuses = await this.attachLiveRuntimeMetadataToStatuses( const statuses = await this.attachLiveRuntimeMetadataToStatuses(
teamName, teamName,
snapshotToMemberSpawnStatuses(launchSnapshot) snapshotToMemberSpawnStatuses(launchSnapshot),
{
openCodeSecondaryBootstrapPendingMembers:
this.getOpenCodeSecondaryBootstrapPendingMemberNames(launchSnapshot),
}
); );
const expectedMembers = this.getPersistedLaunchMemberNames(launchSnapshot); const expectedMembers = this.getPersistedLaunchMemberNames(launchSnapshot);
const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses); const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses);
@ -11910,14 +11939,27 @@ export class TeamProvisioningService {
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity; const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
const runtimeDiagnostic = metadata?.runtimeDiagnostic; const runtimeDiagnostic = metadata?.runtimeDiagnostic;
if (metadata?.livenessKind === 'runtime_process') { if (metadata?.livenessKind === 'runtime_process') {
if (elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS) { if (this.isOpenCodeSecondaryLaneMemberInRun(run, memberName)) {
run.memberSpawnStatuses.set(memberName, { this.setOpenCodeRuntimePendingBootstrapStatus(run, memberName, refreshed, {
...refreshed, bootstrapStalled: elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS,
livenessKind: metadata.livenessKind, runtimeDiagnostic:
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.', elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS
runtimeDiagnosticSeverity: 'warning', ? 'Runtime process is alive, but no bootstrap check-in after 5 min.'
livenessLastCheckedAt: nowIso(), : (runtimeDiagnostic ??
'OpenCode runtime process is alive, waiting for bootstrap check-in.'),
runtimeDiagnosticSeverity:
elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS
? 'warning'
: (metadata.runtimeDiagnosticSeverity ?? 'info'),
}); });
if (elapsedMs < MEMBER_BOOTSTRAP_STALL_MS) {
this.scheduleOpenCodeBootstrapStallReevaluation(
run,
memberName,
refreshedFirstSpawnAcceptedAt
);
}
return;
} }
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
return; return;
@ -11988,6 +12030,98 @@ export class TeamProvisioningService {
this.setMemberSpawnStatus(run, memberName, 'error', strictReason); this.setMemberSpawnStatus(run, memberName, 'error', strictReason);
} }
private setOpenCodeRuntimePendingBootstrapStatus(
run: ProvisioningRun,
memberName: string,
current: MemberSpawnStatusEntry,
options: {
bootstrapStalled: boolean;
runtimeDiagnostic: string;
runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity;
}
): void {
const observedAt = nowIso();
const wasBootstrapStalled = current.bootstrapStalled === true;
const next: MemberSpawnStatusEntry = {
...current,
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
error: undefined,
hardFailureReason: undefined,
livenessSource: undefined,
livenessKind: 'runtime_process',
runtimeDiagnostic: options.runtimeDiagnostic,
runtimeDiagnosticSeverity: options.runtimeDiagnosticSeverity,
bootstrapStalled: options.bootstrapStalled ? true : undefined,
livenessLastCheckedAt: observedAt,
firstSpawnAcceptedAt: current.firstSpawnAcceptedAt ?? observedAt,
updatedAt: observedAt,
};
run.memberSpawnStatuses.set(memberName, next);
const launchDiagnostics = boundLaunchDiagnostics(buildLaunchDiagnosticsFromRun(run));
if (launchDiagnostics) {
run.progress = {
...run.progress,
updatedAt: observedAt,
launchDiagnostics,
};
run.onProgress(run.progress);
}
if (options.bootstrapStalled && !wasBootstrapStalled) {
this.appendMemberBootstrapDiagnostic(run, memberName, 'opencode_bootstrap_stalled');
} else if (
!options.bootstrapStalled &&
(current.status !== 'waiting' || current.livenessKind !== 'runtime_process')
) {
this.appendMemberBootstrapDiagnostic(
run,
memberName,
'runtime process is alive, teammate check-in not yet received'
);
}
if (!this.isCurrentTrackedRun(run)) return;
this.emitMemberSpawnChange(run, memberName);
if (run.isLaunch) {
void this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active');
}
}
private scheduleOpenCodeBootstrapStallReevaluation(
run: ProvisioningRun,
memberName: string,
firstSpawnAcceptedAt: string
): void {
const acceptedAtMs = Date.parse(firstSpawnAcceptedAt);
if (!Number.isFinite(acceptedAtMs)) {
return;
}
const stallDelayMs = Math.max(1_000, acceptedAtMs + MEMBER_BOOTSTRAP_STALL_MS - Date.now());
const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`;
if (this.pendingTimeouts.has(stallKey)) {
return;
}
const timer = setTimeout(() => {
this.pendingTimeouts.delete(stallKey);
void this.reevaluateMemberLaunchStatus(run, memberName);
}, stallDelayMs);
timer.unref?.();
this.pendingTimeouts.set(stallKey, timer);
}
private isOpenCodeBootstrapStallWindowElapsed(firstSpawnAcceptedAt: string | undefined): boolean {
if (!firstSpawnAcceptedAt) {
return false;
}
const acceptedAtMs = Date.parse(firstSpawnAcceptedAt);
return Number.isFinite(acceptedAtMs) && Date.now() - acceptedAtMs >= MEMBER_BOOTSTRAP_STALL_MS;
}
private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean { private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean {
if (!run.expectedMembers || run.expectedMembers.length === 0) { if (!run.expectedMembers || run.expectedMembers.length === 0) {
return true; return true;
@ -17343,6 +17477,23 @@ export class TeamProvisioningService {
// registered the runtime and the OS process is still alive, treat it as // registered the runtime and the OS process is still alive, treat it as
// process-confirmed running. Keep this distinct from heartbeat-confirmed online. // process-confirmed running. Keep this distinct from heartbeat-confirmed online.
if (runtimeAlive) { if (runtimeAlive) {
if (this.isOpenCodeSecondaryLaneMemberInRun(run, expected)) {
const base = current ?? createInitialMemberSpawnStatusEntry();
const bootstrapStalled =
base.bootstrapStalled === true ||
this.isOpenCodeBootstrapStallWindowElapsed(base.firstSpawnAcceptedAt);
this.setOpenCodeRuntimePendingBootstrapStatus(run, expected, base, {
bootstrapStalled,
runtimeDiagnostic: bootstrapStalled
? 'Runtime process is alive, but no bootstrap check-in after 5 min.'
: (base.runtimeDiagnostic ??
'OpenCode runtime process is alive, waiting for bootstrap check-in.'),
runtimeDiagnosticSeverity: bootstrapStalled
? 'warning'
: (base.runtimeDiagnosticSeverity ?? 'info'),
});
continue;
}
this.setMemberSpawnStatus(run, expected, 'online', undefined, 'process'); this.setMemberSpawnStatus(run, expected, 'online', undefined, 'process');
continue; continue;
} }
@ -17431,7 +17582,10 @@ export class TeamProvisioningService {
private async attachLiveRuntimeMetadataToStatuses( private async attachLiveRuntimeMetadataToStatuses(
teamName: string, teamName: string,
statuses: Record<string, MemberSpawnStatusEntry> statuses: Record<string, MemberSpawnStatusEntry>,
options?: {
openCodeSecondaryBootstrapPendingMembers?: ReadonlySet<string>;
}
): Promise<Record<string, MemberSpawnStatusEntry>> { ): Promise<Record<string, MemberSpawnStatusEntry>> {
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
const nextStatuses = { ...statuses }; const nextStatuses = { ...statuses };
@ -17452,6 +17606,15 @@ export class TeamProvisioningService {
if (!current) { if (!current) {
continue; continue;
} }
const openCodeSecondaryBootstrapPending =
options?.openCodeSecondaryBootstrapPendingMembers?.has(resolvedStatusKey) === true &&
current.launchState === 'runtime_pending_bootstrap' &&
current.bootstrapConfirmed !== true &&
current.hardFailure !== true;
const openCodeBootstrapStalled =
openCodeSecondaryBootstrapPending &&
(current.bootstrapStalled === true ||
this.isOpenCodeBootstrapStallWindowElapsed(current.firstSpawnAcceptedAt));
if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) { if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) {
nextStatuses[resolvedStatusKey] = { nextStatuses[resolvedStatusKey] = {
...current, ...current,
@ -17487,6 +17650,8 @@ export class TeamProvisioningService {
current.bootstrapConfirmed !== true; current.bootstrapConfirmed !== true;
if ( if (
hasStrongEvidence && hasStrongEvidence &&
!openCodeSecondaryBootstrapPending &&
current.bootstrapStalled !== true &&
current.hardFailure !== true && current.hardFailure !== true &&
current.launchState !== 'failed_to_start' current.launchState !== 'failed_to_start'
) { ) {
@ -17499,6 +17664,27 @@ export class TeamProvisioningService {
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
nextEntry.launchState = deriveMemberLaunchState(nextEntry); nextEntry.launchState = deriveMemberLaunchState(nextEntry);
} }
if (
(current.bootstrapStalled === true || openCodeSecondaryBootstrapPending) &&
hasStrongEvidence &&
current.bootstrapConfirmed !== true &&
current.launchState !== 'failed_to_start'
) {
nextEntry.status = 'waiting';
nextEntry.agentToolAccepted = true;
nextEntry.runtimeAlive = true;
nextEntry.hardFailure = false;
nextEntry.hardFailureReason = undefined;
nextEntry.error = undefined;
nextEntry.livenessSource = undefined;
nextEntry.bootstrapStalled = openCodeBootstrapStalled ? true : undefined;
if (openCodeBootstrapStalled) {
nextEntry.runtimeDiagnostic =
'Runtime process is alive, but no bootstrap check-in after 5 min.';
nextEntry.runtimeDiagnosticSeverity = 'warning';
}
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
}
if ( if (
hasStrongEvidence && hasStrongEvidence &&
current.launchState === 'failed_to_start' && current.launchState === 'failed_to_start' &&
@ -17538,6 +17724,27 @@ export class TeamProvisioningService {
return nextStatuses; return nextStatuses;
} }
private getOpenCodeSecondaryBootstrapPendingMemberNames(
snapshot: PersistedTeamLaunchSnapshot | null | undefined
): ReadonlySet<string> {
if (!snapshot) {
return new Set();
}
const names = Object.entries(snapshot.members)
.filter(([, member]) => {
return (
member.providerId === 'opencode' &&
member.laneKind === 'secondary' &&
member.laneOwnerProviderId === 'opencode' &&
member.launchState === 'runtime_pending_bootstrap' &&
member.bootstrapConfirmed !== true &&
member.hardFailure !== true
);
})
.map(([name]) => name);
return new Set(names);
}
private async getLiveTeamAgentNames(teamName: string): Promise<Set<string>> { private async getLiveTeamAgentNames(teamName: string): Promise<Set<string>> {
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
return new Set( return new Set(

View file

@ -140,6 +140,7 @@ export const MemberCard = ({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
@ -157,6 +158,7 @@ export const MemberCard = ({
const launchStatusLabel = launchPresentation.launchStatusLabel; const launchStatusLabel = launchPresentation.launchStatusLabel;
const displayPresenceLabel = const displayPresenceLabel =
launchVisualState === 'queued' || launchVisualState === 'queued' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||

View file

@ -50,6 +50,8 @@ import type {
const OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE = const OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE =
'No OpenCode runtime session was recorded. Relaunch this teammate to start a fresh OpenCode session.'; 'No OpenCode runtime session was recorded. Relaunch this teammate to start a fresh OpenCode session.';
const OPENCODE_BOOTSTRAP_STALLED_MESSAGE =
'OpenCode process is alive, but bootstrap did not confirm. Relaunch this teammate to start a fresh OpenCode session.';
function hasOpenCodeRuntimeEvidence(runtimeEntry: TeamAgentRuntimeEntry | undefined): boolean { function hasOpenCodeRuntimeEvidence(runtimeEntry: TeamAgentRuntimeEntry | undefined): boolean {
const hasPid = const hasPid =
@ -202,12 +204,17 @@ export const MemberDetailDialog = ({
const launchErrorMessage = launchDiagnosticsPayload const launchErrorMessage = launchDiagnosticsPayload
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload) ? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
: undefined; : undefined;
const openCodeBootstrapStalled =
member?.providerId === 'opencode' && spawnEntry?.bootstrapStalled === true;
const openCodeNoRuntimeEvidence = member const openCodeNoRuntimeEvidence = member
? isOpenCodeNoRuntimeEvidenceFailure(member, spawnEntry, runtimeEntry) ? isOpenCodeNoRuntimeEvidenceFailure(member, spawnEntry, runtimeEntry)
: false; : false;
const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence
? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE ? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE
: launchErrorMessage; : launchErrorMessage;
const effectiveLaunchInfoMessage = openCodeBootstrapStalled
? OPENCODE_BOOTSTRAP_STALLED_MESSAGE
: undefined;
const restartButtonLabel = const restartButtonLabel =
openCodeNoRuntimeEvidence || openCodeRelaunchActionable ? 'Relaunch OpenCode' : 'Restart'; openCodeNoRuntimeEvidence || openCodeRelaunchActionable ? 'Relaunch OpenCode' : 'Restart';
@ -245,6 +252,7 @@ export const MemberDetailDialog = ({
spawnLaunchState={spawnEntry?.launchState} spawnLaunchState={spawnEntry?.launchState}
spawnLivenessSource={spawnEntry?.livenessSource} spawnLivenessSource={spawnEntry?.livenessSource}
spawnRuntimeAlive={spawnEntry?.runtimeAlive} spawnRuntimeAlive={spawnEntry?.runtimeAlive}
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
runtimeEntry={runtimeEntry} runtimeEntry={runtimeEntry}
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}
onUpdateRole={ onUpdateRole={
@ -351,6 +359,12 @@ export const MemberDetailDialog = ({
/> />
) : null} ) : null}
</div> </div>
) : effectiveLaunchInfoMessage ? (
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-amber-300">
<span className="min-w-0 truncate" title={effectiveLaunchInfoMessage}>
{effectiveLaunchInfoMessage}
</span>
</div>
) : runtimeEntry?.pid ? ( ) : runtimeEntry?.pid ? (
<div className="mr-auto text-xs text-[var(--color-text-muted)]"> <div className="mr-auto text-xs text-[var(--color-text-muted)]">
PID {runtimeEntry.pid} PID {runtimeEntry.pid}

View file

@ -38,6 +38,7 @@ interface MemberDetailHeaderProps {
spawnLaunchState?: MemberLaunchState; spawnLaunchState?: MemberLaunchState;
spawnLivenessSource?: MemberSpawnLivenessSource; spawnLivenessSource?: MemberSpawnLivenessSource;
spawnRuntimeAlive?: boolean; spawnRuntimeAlive?: boolean;
spawnBootstrapStalled?: boolean;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void; onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
updatingRole?: boolean; updatingRole?: boolean;
@ -54,6 +55,7 @@ export const MemberDetailHeader = ({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapStalled,
isLaunchSettling, isLaunchSettling,
onUpdateRole, onUpdateRole,
updatingRole, updatingRole,
@ -79,6 +81,7 @@ export const MemberDetailHeader = ({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapStalled,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
@ -96,7 +99,8 @@ export const MemberDetailHeader = ({
const badgeLabel = const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel ? runtimeAdvisoryLabel
: launchVisualState === 'runtime_pending' || : launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' || launchVisualState === 'runtime_candidate' ||

View file

@ -130,6 +130,7 @@ export const MemberHoverCard = ({
spawnLaunchState: spawnEntry?.launchState, spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource, spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
@ -147,7 +148,8 @@ export const MemberHoverCard = ({
const badgeLabel = const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel ? runtimeAdvisoryLabel
: launchVisualState === 'runtime_pending' || : launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' || launchVisualState === 'runtime_candidate' ||

View file

@ -65,7 +65,11 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
} }
function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean { function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process'; return (
entry.runtimeAlive === true &&
entry.livenessKind === 'runtime_process' &&
entry.bootstrapStalled !== true
);
} }
function shouldPreferSnapshotEntryOverLive( function shouldPreferSnapshotEntryOverLive(

View file

@ -550,6 +550,7 @@ export type MemberLaunchVisualState =
| 'waiting' | 'waiting'
| 'spawning' | 'spawning'
| 'permission_pending' | 'permission_pending'
| 'bootstrap_stalled'
| 'runtime_pending' | 'runtime_pending'
| 'shell_only' | 'shell_only'
| 'runtime_candidate' | 'runtime_candidate'
@ -582,6 +583,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
return 'starting'; return 'starting';
case 'permission_pending': case 'permission_pending':
return 'awaiting permission'; return 'awaiting permission';
case 'bootstrap_stalled':
return 'bootstrap stalled';
case 'runtime_pending': case 'runtime_pending':
return 'waiting for bootstrap'; return 'waiting for bootstrap';
case 'shell_only': case 'shell_only':
@ -608,6 +611,7 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
case 'queued': case 'queued':
return SPAWN_DOT_COLORS.waiting; return SPAWN_DOT_COLORS.waiting;
case 'permission_pending': case 'permission_pending':
case 'bootstrap_stalled':
case 'runtime_pending': case 'runtime_pending':
case 'runtime_candidate': case 'runtime_candidate':
return 'bg-amber-400 animate-pulse'; return 'bg-amber-400 animate-pulse';
@ -710,6 +714,7 @@ export function isOpenCodeRelaunchActionable({
nowMs nowMs
); );
const hasExplicitBootstrapStall = const hasExplicitBootstrapStall =
spawnEntry?.bootstrapStalled === true ||
hasBootstrapStallDiagnostic(spawnEntry?.runtimeDiagnostic) || hasBootstrapStallDiagnostic(spawnEntry?.runtimeDiagnostic) ||
hasBootstrapStallDiagnostic(runtimeEntry?.runtimeDiagnostic); hasBootstrapStallDiagnostic(runtimeEntry?.runtimeDiagnostic);
const launchIsNoLongerFresh = const launchIsNoLongerFresh =
@ -725,6 +730,9 @@ export function isOpenCodeRelaunchActionable({
) { ) {
return launchIsNoLongerFresh; return launchIsNoLongerFresh;
} }
if (livenessKind === 'runtime_process') {
return hasExplicitBootstrapStall;
}
if (livenessKind !== 'runtime_process_candidate') { if (livenessKind !== 'runtime_process_candidate') {
return false; return false;
} }
@ -738,6 +746,7 @@ export function buildMemberLaunchPresentation({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapStalled,
runtimeAdvisory, runtimeAdvisory,
runtimeEntry, runtimeEntry,
isLaunchSettling = false, isLaunchSettling = false,
@ -750,6 +759,7 @@ export function buildMemberLaunchPresentation({
spawnLaunchState: MemberLaunchState | undefined; spawnLaunchState: MemberLaunchState | undefined;
spawnLivenessSource: MemberSpawnLivenessSource | undefined; spawnLivenessSource: MemberSpawnLivenessSource | undefined;
spawnRuntimeAlive: boolean | undefined; spawnRuntimeAlive: boolean | undefined;
spawnBootstrapStalled?: boolean;
runtimeAdvisory: MemberRuntimeAdvisory | undefined; runtimeAdvisory: MemberRuntimeAdvisory | undefined;
runtimeEntry?: TeamAgentRuntimeEntry; runtimeEntry?: TeamAgentRuntimeEntry;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
@ -800,6 +810,8 @@ export function buildMemberLaunchPresentation({
launchVisualState = 'skipped'; launchVisualState = 'skipped';
} else if (spawnLaunchState === 'runtime_pending_permission') { } else if (spawnLaunchState === 'runtime_pending_permission') {
launchVisualState = 'permission_pending'; launchVisualState = 'permission_pending';
} else if (spawnBootstrapStalled === true) {
launchVisualState = 'bootstrap_stalled';
} else if (runtimeEntry?.livenessKind === 'shell_only') { } else if (runtimeEntry?.livenessKind === 'shell_only') {
launchVisualState = 'shell_only'; launchVisualState = 'shell_only';
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') { } else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') {
@ -851,6 +863,7 @@ export function buildMemberLaunchPresentation({
const shouldShowLaunchStatusAsPresence = const shouldShowLaunchStatusAsPresence =
launchVisualState === 'queued' || launchVisualState === 'queued' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' || launchVisualState === 'runtime_candidate' ||

View file

@ -27,6 +27,7 @@ export interface MemberLaunchDiagnosticsPayload {
runtimeSessionId?: string; runtimeSessionId?: string;
runtimeDiagnostic?: string; runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
bootstrapStalled?: boolean;
diagnostics?: string[]; diagnostics?: string[];
updatedAt?: string; updatedAt?: string;
} }
@ -139,6 +140,7 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity, spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
} }
: {}), : {}),
...(spawnEntry?.bootstrapStalled === true ? { bootstrapStalled: true } : {}),
...(diagnostics ? { diagnostics } : {}), ...(diagnostics ? { diagnostics } : {}),
...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) ...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt)
? { updatedAt: boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) } ? { updatedAt: boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) }
@ -159,6 +161,7 @@ export function hasMemberLaunchDiagnosticsDetails(
return Boolean( return Boolean(
(payload.launchState && payload.launchState !== 'confirmed_alive') || (payload.launchState && payload.launchState !== 'confirmed_alive') ||
(payload.spawnStatus && payload.spawnStatus !== 'online') || (payload.spawnStatus && payload.spawnStatus !== 'online') ||
payload.bootstrapStalled === true ||
weakLiveness || weakLiveness ||
payload.runtimeDiagnostic || payload.runtimeDiagnostic ||
payload.diagnostics?.length payload.diagnostics?.length

View file

@ -42,6 +42,7 @@ interface SkippedSpawnDetail {
} }
type PendingDiagnosticBucket = type PendingDiagnosticBucket =
| 'bootstrapStalled'
| 'shellOnly' | 'shellOnly'
| 'runtimeProcess' | 'runtimeProcess'
| 'runtimeCandidate' | 'runtimeCandidate'
@ -182,6 +183,7 @@ function getPendingDiagnosticNameGroups(params: {
memberSpawnSnapshotUpdatedAt?: string; memberSpawnSnapshotUpdatedAt?: string;
}): PendingDiagnosticNameGroups { }): PendingDiagnosticNameGroups {
const groups: PendingDiagnosticNameGroups = { const groups: PendingDiagnosticNameGroups = {
bootstrapStalled: [],
shellOnly: [], shellOnly: [],
runtimeProcess: [], runtimeProcess: [],
runtimeCandidate: [], runtimeCandidate: [],
@ -215,6 +217,10 @@ function getPendingDiagnosticNameGroups(params: {
groups.permission.push(name); groups.permission.push(name);
continue; continue;
} }
if (entry.bootstrapStalled === true) {
groups.bootstrapStalled.push(name);
continue;
}
if (entry.livenessKind === 'shell_only') { if (entry.livenessKind === 'shell_only') {
groups.shellOnly.push(name); groups.shellOnly.push(name);
} else if (entry.livenessKind === 'runtime_process') { } else if (entry.livenessKind === 'runtime_process') {
@ -288,9 +294,24 @@ function buildOpenCodeSecondaryWaitPhrase(params: {
const pendingOnlyOpenCodeSecondary = pendingNames.every((name) => const pendingOnlyOpenCodeSecondary = pendingNames.every((name) =>
isOpenCodeSecondaryMember(memberByName.get(name)) isOpenCodeSecondaryMember(memberByName.get(name))
); );
return pendingOnlyOpenCodeSecondary if (!pendingOnlyOpenCodeSecondary) {
? `Waiting for OpenCode: ${formatMemberNameList(pendingNames)}` return null;
: null; }
const groups = getPendingDiagnosticNameGroups({
memberSpawnStatuses: params.memberSpawnStatuses,
memberSpawnSnapshotStatuses: params.memberSpawnSnapshotStatuses,
memberSpawnSnapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
if (groups.bootstrapStalled.length === 0) {
return `Waiting for OpenCode: ${formatMemberNameList(pendingNames)}`;
}
const stalled = `Bootstrap stalled: ${formatMemberNameList(groups.bootstrapStalled)}`;
const waitingNames = pendingNames.filter((name) => !groups.bootstrapStalled.includes(name));
return waitingNames.length > 0
? `${stalled}; Waiting for OpenCode: ${formatMemberNameList(waitingNames)}`
: stalled;
} }
function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null { function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null {
@ -323,6 +344,7 @@ function buildPendingDiagnosticPhrase({
memberSpawnSnapshotUpdatedAt, memberSpawnSnapshotUpdatedAt,
}); });
const namedParts = [ const namedParts = [
formatNamedPendingDiagnostic('Bootstrap stalled', groups.bootstrapStalled),
formatNamedPendingDiagnostic('Shell-only', groups.shellOnly), formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess), formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
formatNamedPendingDiagnostic('Bootstrap unconfirmed', groups.runtimeCandidate), formatNamedPendingDiagnostic('Bootstrap unconfirmed', groups.runtimeCandidate),

View file

@ -1022,6 +1022,8 @@ export interface PersistedTeamLaunchMemberState {
pidSource?: TeamAgentRuntimePidSource; pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string; runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
/** True when a live runtime process missed the bounded bootstrap check-in window. */
bootstrapStalled?: boolean;
runtimeLastSeenAt?: string; runtimeLastSeenAt?: string;
firstSpawnAcceptedAt?: string; firstSpawnAcceptedAt?: string;
lastHeartbeatAt?: string; lastHeartbeatAt?: string;
@ -1199,6 +1201,8 @@ export interface MemberSpawnStatusEntry {
runtimeDiagnostic?: string; runtimeDiagnostic?: string;
/** Visual severity for runtimeDiagnostic. */ /** Visual severity for runtimeDiagnostic. */
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
/** Process is alive, but bootstrap did not confirm before the bounded OpenCode deadline. */
bootstrapStalled?: boolean;
/** ISO timestamp of the last liveness evaluation. */ /** ISO timestamp of the last liveness evaluation. */
livenessLastCheckedAt?: string; livenessLastCheckedAt?: string;
/** ISO timestamp of the last status change. */ /** ISO timestamp of the last status change. */

View file

@ -14796,7 +14796,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.launchInputs).toHaveLength(0); expect(adapter.launchInputs).toHaveLength(0);
expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([
'launching', 'launching',
'launching', 'queued',
]); ]);
expect(run.mixedSecondaryLanes.map((lane: { runId: string | null }) => lane.runId)).toEqual( expect(run.mixedSecondaryLanes.map((lane: { runId: string | null }) => lane.runId)).toEqual(
firstLaneRunIds firstLaneRunIds
@ -14847,7 +14847,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.launchInputs).toHaveLength(0); expect(adapter.launchInputs).toHaveLength(0);
expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([
'launching', 'launching',
'launching', 'queued',
]); ]);
expect(run.mixedSecondaryLanes.map((lane: { runId: string | null }) => lane.runId)).toEqual( expect(run.mixedSecondaryLanes.map((lane: { runId: string | null }) => lane.runId)).toEqual(
firstLaneRunIds firstLaneRunIds

View file

@ -223,6 +223,86 @@ describe('TeamLaunchStateEvaluator', () => {
}); });
}); });
it('keeps bootstrap-stalled runtime processes pending instead of online', () => {
const snapshot = normalizePersistedLaunchSnapshot('my-team', {
version: 2,
teamName: 'my-team',
updatedAt: '2026-04-23T00:00:00.000Z',
launchPhase: 'active',
expectedMembers: ['alice'],
members: {
alice: {
name: 'alice',
providerId: 'opencode',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
laneId: 'secondary:opencode:alice',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
livenessKind: 'runtime_process',
bootstrapStalled: true,
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
runtimeDiagnosticSeverity: 'warning',
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
},
},
});
expect(snapshot?.members.alice.bootstrapStalled).toBe(true);
expect(snapshot?.teamLaunchState).toBe('partial_pending');
const statuses = snapshotToMemberSpawnStatuses(snapshot);
expect(statuses.alice).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: undefined,
livenessKind: 'runtime_process',
bootstrapStalled: true,
});
});
it('keeps OpenCode secondary runtime processes pending before bootstrap stalls', () => {
const snapshot = normalizePersistedLaunchSnapshot('my-team', {
version: 2,
teamName: 'my-team',
updatedAt: '2026-04-23T00:00:00.000Z',
launchPhase: 'active',
expectedMembers: ['alice'],
members: {
alice: {
name: 'alice',
providerId: 'opencode',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
laneId: 'secondary:opencode:alice',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
livenessKind: 'runtime_process',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
},
},
});
const statuses = snapshotToMemberSpawnStatuses(snapshot);
expect(statuses.alice).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: undefined,
livenessKind: 'runtime_process',
bootstrapStalled: false,
});
});
it('normalizes stale persisted runtimeAlive to false without strong liveness evidence', () => { it('normalizes stale persisted runtimeAlive to false without strong liveness evidence', () => {
const snapshot = normalizePersistedLaunchSnapshot('demo', { const snapshot = normalizePersistedLaunchSnapshot('demo', {
version: 2, version: 2,

View file

@ -431,6 +431,7 @@ function createMemberSpawnRun(params?: {
expectedMembers?: string[]; expectedMembers?: string[];
memberSpawnStatuses?: Map<string, Record<string, unknown>>; memberSpawnStatuses?: Map<string, Record<string, unknown>>;
memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>; memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>;
mixedSecondaryLanes?: Array<{ providerId: string; member: { name: string } }>;
}) { }) {
const teamName = params?.teamName ?? 'member-spawn-team'; const teamName = params?.teamName ?? 'member-spawn-team';
const expectedMembers = params?.expectedMembers ?? ['alice']; const expectedMembers = params?.expectedMembers ?? ['alice'];
@ -452,6 +453,7 @@ function createMemberSpawnRun(params?: {
request: { request: {
members: [], members: [],
}, },
mixedSecondaryLanes: params?.mixedSecondaryLanes ?? [],
expectedMembers, expectedMembers,
memberSpawnStatuses, memberSpawnStatuses,
memberSpawnToolUseIds: new Map(), memberSpawnToolUseIds: new Map(),
@ -9202,6 +9204,7 @@ describe('TeamProvisioningService', () => {
const run = createMemberSpawnRun({ const run = createMemberSpawnRun({
teamName: 'codex-team', teamName: 'codex-team',
expectedMembers: ['bob'], expectedMembers: ['bob'],
mixedSecondaryLanes: [{ providerId: 'opencode', member: { name: 'bob' } }],
memberSpawnStatuses: new Map([ memberSpawnStatuses: new Map([
[ [
'bob', 'bob',
@ -9235,14 +9238,67 @@ describe('TeamProvisioningService', () => {
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob'); await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'online', status: 'waiting',
launchState: 'runtime_pending_bootstrap', launchState: 'runtime_pending_bootstrap',
runtimeAlive: true, runtimeAlive: true,
bootstrapConfirmed: false, bootstrapConfirmed: false,
livenessSource: 'process', livenessSource: undefined,
livenessKind: 'runtime_process', livenessKind: 'runtime_process',
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.', runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
runtimeDiagnosticSeverity: 'warning', runtimeDiagnosticSeverity: 'warning',
bootstrapStalled: true,
hardFailure: false,
});
});
it('keeps OpenCode runtime process pending before the bootstrap stall window', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
mixedSecondaryLanes: [{ providerId: 'opencode', member: { name: 'bob' } }],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
firstSpawnAcceptedAt: new Date(Date.now() - 60_000).toISOString(),
}),
],
]),
});
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'bob',
{
alive: true,
livenessKind: 'runtime_process',
runtimeDiagnostic: 'OpenCode runtime process detected',
},
],
])
);
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessSource: undefined,
livenessKind: 'runtime_process',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
bootstrapStalled: undefined,
hardFailure: false, hardFailure: false,
}); });
}); });
@ -12894,6 +12950,101 @@ describe('TeamProvisioningService', () => {
}); });
}); });
it('keeps OpenCode secondary pending-bootstrap status waiting when live runtime process is attached', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'tom',
{
alive: true,
model: 'openrouter/minimax/minimax-m2.5',
livenessKind: 'runtime_process',
providerId: 'opencode',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
},
],
])
);
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses(
'beacon-desk-4',
{
tom: createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
}),
},
{ openCodeSecondaryBootstrapPendingMembers: new Set(['tom']) }
);
expect(result.tom).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessSource: undefined,
livenessKind: 'runtime_process',
runtimeModel: 'openrouter/minimax/minimax-m2.5',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
});
});
it('marks stale OpenCode secondary pending-bootstrap status stalled when live runtime is attached after restart', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'tom',
{
alive: true,
model: 'openrouter/minimax/minimax-m2.5',
livenessKind: 'runtime_process',
providerId: 'opencode',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
},
],
])
);
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses(
'beacon-desk-4',
{
tom: createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
firstSpawnAcceptedAt: new Date(Date.now() - 6 * 60_000).toISOString(),
}),
},
{ openCodeSecondaryBootstrapPendingMembers: new Set(['tom']) }
);
expect(result.tom).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessSource: undefined,
livenessKind: 'runtime_process',
bootstrapStalled: true,
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
runtimeDiagnosticSeverity: 'warning',
});
});
it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => { it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(

View file

@ -413,4 +413,82 @@ describe('MemberDetailDialog activity count', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('shows Relaunch OpenCode copy for stalled OpenCode bootstrap', async () => {
const member: ResolvedTeamMember = {
name: 'tom',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
providerId: 'opencode',
};
const onRestartMember = vi.fn(async () => undefined);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
members: [member],
tasks: [],
isTeamAlive: true,
spawnEntry: {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
livenessKind: 'runtime_process',
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
runtimeDiagnosticSeverity: 'warning',
bootstrapStalled: true,
updatedAt: '2026-04-24T12:05:00.000Z',
},
runtimeEntry: {
memberName: 'tom',
alive: true,
restartable: true,
providerId: 'opencode',
livenessKind: 'runtime_process',
runtimeDiagnostic: 'OpenCode runtime process detected',
runtimeDiagnosticSeverity: 'info',
updatedAt: '2026-04-24T12:05:01.000Z',
},
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
onRestartMember,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain(
'OpenCode process is alive, but bootstrap did not confirm. Relaunch this teammate to start a fresh OpenCode session.'
);
const relaunchButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Relaunch OpenCode')
);
expect(relaunchButton).not.toBeUndefined();
await act(async () => {
relaunchButton?.click();
await Promise.resolve();
});
expect(onRestartMember).toHaveBeenCalledWith('tom');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -65,6 +65,25 @@ describe('getLaunchJoinMilestonesFromMembers', () => {
expect(milestones.pendingSpawnCount).toBe(4); expect(milestones.pendingSpawnCount).toBe(4);
}); });
it('keeps bootstrap-stalled runtime processes out of process-alive progress', () => {
const milestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses: {
alice: {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessKind: 'runtime_process',
bootstrapStalled: true,
updatedAt: '2026-04-24T12:05:00.000Z',
},
},
});
expect(milestones.processOnlyAliveCount).toBe(0);
expect(milestones.pendingSpawnCount).toBe(4);
});
it('uses runtimeProcessPendingCount instead of legacy runtimeAlivePendingCount for snapshot pending math', () => { it('uses runtimeProcessPendingCount instead of legacy runtimeAlivePendingCount for snapshot pending math', () => {
const milestones = getLaunchJoinMilestonesFromMembers({ const milestones = getLaunchJoinMilestonesFromMembers({
members, members,

View file

@ -303,6 +303,26 @@ describe('memberHelpers spawn-aware presence', () => {
expect(permissionPending.cardClass).toContain('member-waiting-shimmer'); expect(permissionPending.cardClass).toContain('member-waiting-shimmer');
}); });
it('surfaces bootstrap-stalled OpenCode teammates as actionable pending state', () => {
const bootstrapStalled = buildMemberLaunchPresentation({
member: { ...member, providerId: 'opencode' },
spawnStatus: 'waiting',
spawnLaunchState: 'runtime_pending_bootstrap',
spawnLivenessSource: undefined,
spawnRuntimeAlive: true,
spawnBootstrapStalled: true,
runtimeAdvisory: undefined,
isLaunchSettling: false,
isTeamAlive: true,
isTeamProvisioning: false,
});
expect(bootstrapStalled.presenceLabel).toBe('bootstrap stalled');
expect(bootstrapStalled.launchVisualState).toBe('bootstrap_stalled');
expect(bootstrapStalled.launchStatusLabel).toBe('bootstrap stalled');
expect(bootstrapStalled.dotClass).toContain('bg-amber-400');
});
it('surfaces strict runtime liveness diagnostics as launch labels', () => { it('surfaces strict runtime liveness diagnostics as launch labels', () => {
expect( expect(
buildMemberLaunchPresentation({ buildMemberLaunchPresentation({

View file

@ -667,6 +667,84 @@ describe('buildTeamProvisioningPresentation', () => {
expect(presentation?.currentStepIndex).toBe(2); expect(presentation?.currentStepIndex).toBe(2);
}); });
it('shows stalled OpenCode secondaries separately from normal bootstrap waiting', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {
runId: 'run-opencode-secondary-stalled',
teamName: 'mixed-team',
state: 'ready',
startedAt: '2026-04-13T10:00:00.000Z',
updatedAt: '2026-04-13T10:05:08.000Z',
message: 'Team provisioned - waiting for secondary runtime lane: tom',
messageSeverity: undefined,
pid: 4321,
cliLogsTail: '',
assistantOutput: '',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
providerId: 'codex',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'alice',
providerId: 'codex',
laneKind: 'primary',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'tom',
providerId: 'opencode',
laneId: 'secondary:opencode:tom',
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
status: 'unknown',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
memberSpawnStatuses: {
alice: {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-04-13T10:00:05.000Z',
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
agentToolAccepted: true,
},
tom: {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-13T10:05:07.000Z',
runtimeAlive: true,
livenessKind: 'runtime_process',
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
bootstrapStalled: true,
},
},
});
expect(presentation?.successMessage).toBe('Core team ready');
expect(presentation?.panelMessage).toBe('Bootstrap stalled: tom');
expect(presentation?.compactDetail).toBe('Bootstrap stalled: tom');
expect(presentation?.currentStepIndex).toBe(2);
});
it('does not show core team ready while a primary member is still joining', () => { it('does not show core team ready while a primary member is still joining', () => {
const presentation = buildTeamProvisioningPresentation({ const presentation = buildTeamProvisioningPresentation({
progress: { progress: {