diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts
index e5fbb883..f8ade973 100644
--- a/src/renderer/components/runtime/providerConnectionUi.ts
+++ b/src/renderer/components/runtime/providerConnectionUi.ts
@@ -425,8 +425,8 @@ export function formatProviderStatusText(
if (isProviderInventoryOnlyFallback(provider)) {
return translateProviderConnection(
t,
- 'providerRuntime.connectionUi.status.checking',
- 'Checking...'
+ 'providerRuntime.connectionUi.status.modelsAvailable',
+ 'Models available'
);
}
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index ce5c61b4..3e16b9fb 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -862,9 +862,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
setProviderTerminal(null);
recheckStatus();
}}
- onExit={() => {
- recheckStatus();
- }}
autoCloseOnSuccessMs={3000}
successMessage={
providerTerminal.action === 'login'
diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx
index 300abf4e..9db050f9 100644
--- a/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx
+++ b/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx
@@ -180,10 +180,6 @@ export const ProvisioningProviderRuntimeSettingsDialog = ({
onProviderRuntimeChanged?.(providerTerminal.providerId);
refreshRuntimeAfterTerminal();
}}
- onExit={() => {
- onProviderRuntimeChanged?.(providerTerminal.providerId);
- refreshRuntimeAfterTerminal();
- }}
autoCloseOnSuccessMs={3000}
successMessage={
providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 9cfd5332..034b0373 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -28,6 +28,10 @@ import {
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
+import {
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext,
+ isBootstrapConfirmedProvisionedButNotAliveFailure,
+} from '@shared/utils/teamLaunchFailureReason';
import {
Activity,
AlertTriangle,
@@ -661,12 +665,20 @@ export const MemberCard = memo(function MemberCard({
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
+ const bootstrapConfirmedProvisionedButNotAlive =
+ isBootstrapConfirmedProvisionedButNotAliveFailure(spawnEntry);
+ const hasUnsafeBootstrapConfirmedProvisionedButNotAlive =
+ bootstrapConfirmedProvisionedButNotAlive &&
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(spawnEntry, runtimeEntry);
+ const effectiveSpawnStatus = spawnStatus;
+ const effectiveSpawnLaunchState = spawnLaunchState;
const showTaskActivity = shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
- spawnStatus,
- spawnLaunchState,
+ spawnStatus: effectiveSpawnStatus,
+ spawnLaunchState: effectiveSpawnLaunchState,
spawnRuntimeAlive,
+ spawnEntry,
runtimeEntry,
});
const visibleCurrentTask = showTaskActivity ? currentTask : null;
@@ -680,15 +692,19 @@ export const MemberCard = memo(function MemberCard({
: member;
const launchPresentation = buildMemberLaunchPresentation({
member: presentationMember,
- spawnStatus,
- spawnLaunchState,
+ spawnStatus: effectiveSpawnStatus,
+ spawnLaunchState: effectiveSpawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
spawnAgentToolAccepted: spawnEntry?.agentToolAccepted,
spawnHardFailure: spawnEntry?.hardFailure,
+ spawnHardFailureReason: spawnEntry?.hardFailureReason,
+ spawnError: spawnEntry?.error,
+ spawnRuntimeDiagnostic: spawnEntry?.runtimeDiagnostic,
spawnLivenessKind: spawnEntry?.livenessKind,
+ spawnRuntimeDiagnosticSeverity: spawnEntry?.runtimeDiagnosticSeverity,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry,
@@ -844,7 +860,7 @@ export const MemberCard = memo(function MemberCard({
const showStartingSkeleton =
!isRemoved &&
presenceLabel === 'starting' &&
- spawnLaunchState !== 'failed_to_start' &&
+ effectiveSpawnLaunchState !== 'failed_to_start' &&
!activityTask &&
!runtimeSummary;
const usesLaunchSkeletonSurface = spawnCardClass.includes('member-waiting-shimmer');
@@ -869,8 +885,8 @@ export const MemberCard = memo(function MemberCard({
runId: runtimeRunId,
memberName: member.name,
member,
- spawnStatus,
- launchState: spawnLaunchState,
+ spawnStatus: effectiveSpawnStatus,
+ launchState: effectiveSpawnLaunchState,
livenessSource: spawnLivenessSource,
spawnEntry,
runtimeEntry,
@@ -886,9 +902,9 @@ export const MemberCard = memo(function MemberCard({
runtimeRunId,
selectedTeamName,
spawnEntry,
- spawnLaunchState,
+ effectiveSpawnLaunchState,
spawnLivenessSource,
- spawnStatus,
+ effectiveSpawnStatus,
]
);
const showCopyDiagnostics =
@@ -900,7 +916,10 @@ export const MemberCard = memo(function MemberCard({
Boolean(runtimeAdvisoryLabel) &&
runtimeAdvisoryTone === 'error' &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
- const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
+ const isFailedLaunch =
+ (!bootstrapConfirmedProvisionedButNotAlive ||
+ hasUnsafeBootstrapConfirmedProvisionedButNotAlive) &&
+ (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start');
const isSkippedLaunch =
spawnStatus === 'skipped' ||
spawnLaunchState === 'skipped_for_launch' ||
diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx
index 0a1ee0ec..fa80c2b8 100644
--- a/src/renderer/components/team/members/MemberDetailDialog.tsx
+++ b/src/renderer/components/team/members/MemberDetailDialog.tsx
@@ -24,6 +24,10 @@ import {
} from '@renderer/utils/memberRuntimeSummary';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
+import {
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext,
+ isBootstrapConfirmedProvisionedButNotAliveFailure,
+} from '@shared/utils/teamLaunchFailureReason';
import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState';
import {
BarChart3,
@@ -83,7 +87,14 @@ function isOpenCodeNoRuntimeEvidenceFailure(
spawnEntry: MemberSpawnStatusEntry | undefined,
runtimeEntry: TeamAgentRuntimeEntry | undefined
): boolean {
- const failed = spawnEntry?.launchState === 'failed_to_start' || spawnEntry?.status === 'error';
+ const bootstrapConfirmedProvisionedButNotAlive =
+ isBootstrapConfirmedProvisionedButNotAliveFailure(spawnEntry);
+ const unsafeProvisionedButNotAlive =
+ bootstrapConfirmedProvisionedButNotAlive &&
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(spawnEntry, runtimeEntry);
+ const failed =
+ (!bootstrapConfirmedProvisionedButNotAlive || unsafeProvisionedButNotAlive) &&
+ (spawnEntry?.launchState === 'failed_to_start' || spawnEntry?.status === 'error');
return member.providerId === 'opencode' && failed && !hasOpenCodeRuntimeEvidence(runtimeEntry);
}
@@ -180,6 +191,7 @@ export const MemberDetailDialog = ({
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ spawnEntry,
runtimeEntry,
});
const displayableCurrentTask =
@@ -303,7 +315,11 @@ export const MemberDetailDialog = ({
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
spawnAgentToolAccepted={spawnEntry?.agentToolAccepted}
spawnHardFailure={spawnEntry?.hardFailure}
+ spawnHardFailureReason={spawnEntry?.hardFailureReason}
+ spawnError={spawnEntry?.error}
+ spawnRuntimeDiagnostic={spawnEntry?.runtimeDiagnostic}
spawnLivenessKind={spawnEntry?.livenessKind}
+ spawnRuntimeDiagnosticSeverity={spawnEntry?.runtimeDiagnosticSeverity}
spawnFirstSpawnAcceptedAt={spawnEntry?.firstSpawnAcceptedAt}
spawnUpdatedAt={spawnEntry?.updatedAt}
runtimeEntry={runtimeEntry}
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx
index b1c9acb7..e61d3a17 100644
--- a/src/renderer/components/team/members/MemberDetailHeader.tsx
+++ b/src/renderer/components/team/members/MemberDetailHeader.tsx
@@ -25,6 +25,7 @@ import type {
MemberSpawnLivenessSource,
MemberSpawnStatus,
ResolvedTeamMember,
+ TeamAgentRuntimeDiagnosticSeverity,
TeamAgentRuntimeEntry,
} from '@shared/types';
@@ -43,7 +44,11 @@ interface MemberDetailHeaderProps {
spawnBootstrapStalled?: boolean;
spawnAgentToolAccepted?: boolean;
spawnHardFailure?: boolean;
+ spawnHardFailureReason?: string;
+ spawnError?: string;
+ spawnRuntimeDiagnostic?: string;
spawnLivenessKind?: TeamAgentRuntimeEntry['livenessKind'];
+ spawnRuntimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
spawnFirstSpawnAcceptedAt?: string;
spawnUpdatedAt?: string;
isLaunchSettling?: boolean;
@@ -66,7 +71,11 @@ export const MemberDetailHeader = ({
spawnBootstrapStalled,
spawnAgentToolAccepted,
spawnHardFailure,
+ spawnHardFailureReason,
+ spawnError,
+ spawnRuntimeDiagnostic,
spawnLivenessKind,
+ spawnRuntimeDiagnosticSeverity,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
isLaunchSettling,
@@ -99,7 +108,11 @@ export const MemberDetailHeader = ({
spawnBootstrapStalled,
spawnAgentToolAccepted,
spawnHardFailure,
+ spawnHardFailureReason,
+ spawnError,
+ spawnRuntimeDiagnostic,
spawnLivenessKind,
+ spawnRuntimeDiagnosticSeverity,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
runtimeEntry,
diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx
index 0bc94f54..8957e3d3 100644
--- a/src/renderer/components/team/members/MemberHoverCard.tsx
+++ b/src/renderer/components/team/members/MemberHoverCard.tsx
@@ -147,6 +147,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ spawnEntry,
runtimeEntry,
})
? currentTaskCandidate
@@ -168,7 +169,11 @@ export const MemberHoverCard = memo(function MemberHoverCard({
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
spawnAgentToolAccepted: spawnEntry?.agentToolAccepted,
spawnHardFailure: spawnEntry?.hardFailure,
+ spawnHardFailureReason: spawnEntry?.hardFailureReason,
+ spawnError: spawnEntry?.error,
+ spawnRuntimeDiagnostic: spawnEntry?.runtimeDiagnostic,
spawnLivenessKind: spawnEntry?.livenessKind,
+ spawnRuntimeDiagnosticSeverity: spawnEntry?.runtimeDiagnosticSeverity,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry,
@@ -226,6 +231,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ spawnEntry,
runtimeEntry,
})
? reviewTaskCandidate
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx
index 5e3732a9..5f6ea327 100644
--- a/src/renderer/components/team/members/MemberList.tsx
+++ b/src/renderer/components/team/members/MemberList.tsx
@@ -11,6 +11,10 @@ import { buildMemberColorMap, shouldDisplayMemberCurrentTask } from '@renderer/u
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
+import {
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext,
+ isBootstrapConfirmedProvisionedButNotAliveFailure,
+} from '@shared/utils/teamLaunchFailureReason';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { MemberCard, type RuntimeTelemetryScale } from './MemberCard';
@@ -785,6 +789,7 @@ export const MemberList = memo(function MemberList({
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ spawnEntry,
runtimeEntry,
});
},
@@ -837,6 +842,7 @@ export const MemberList = memo(function MemberList({
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ spawnEntry,
runtimeEntry,
});
syncMemberActivityTimer({
@@ -924,6 +930,32 @@ export const MemberList = memo(function MemberList({
{activeMembers.map((member) => {
const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
+ const bootstrapConfirmedProvisionedButNotAlive =
+ isBootstrapConfirmedProvisionedButNotAliveFailure(spawnEntry);
+ const hasUnsafeProvisionedButNotAliveEvidence =
+ bootstrapConfirmedProvisionedButNotAlive &&
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(
+ spawnEntry,
+ runtimeEntry
+ );
+ const canPromoteBootstrapConfirmedVisualState =
+ bootstrapConfirmedProvisionedButNotAlive &&
+ spawnEntry?.runtimeDiagnosticSeverity !== 'error' &&
+ runtimeEntry?.runtimeDiagnosticSeverity !== 'error' &&
+ !hasUnsafeProvisionedButNotAliveEvidence;
+ const effectiveSpawnStatus = canPromoteBootstrapConfirmedVisualState
+ ? 'online'
+ : spawnEntry?.status;
+ const effectiveSpawnLaunchState = canPromoteBootstrapConfirmedVisualState
+ ? 'confirmed_alive'
+ : spawnEntry?.launchState;
+ const useBootstrapConfirmedRuntimeAlive =
+ canPromoteBootstrapConfirmedVisualState &&
+ runtimeEntry?.runtimeDiagnosticSeverity !== 'error' &&
+ spawnEntry?.runtimeDiagnosticSeverity !== 'error';
+ const effectiveSpawnRuntimeAlive = useBootstrapConfirmedRuntimeAlive
+ ? true
+ : spawnEntry?.runtimeAlive;
const currentTaskCandidate =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const currentTask =
@@ -931,9 +963,10 @@ export const MemberList = memo(function MemberList({
shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
- spawnStatus: spawnEntry?.status,
- spawnLaunchState: spawnEntry?.launchState,
- spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ spawnStatus: effectiveSpawnStatus,
+ spawnLaunchState: effectiveSpawnLaunchState,
+ spawnRuntimeAlive: effectiveSpawnRuntimeAlive,
+ spawnEntry,
runtimeEntry,
})
? currentTaskCandidate
@@ -945,9 +978,10 @@ export const MemberList = memo(function MemberList({
shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
- spawnStatus: spawnEntry?.status,
- spawnLaunchState: spawnEntry?.launchState,
- spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ spawnStatus: effectiveSpawnStatus,
+ spawnLaunchState: effectiveSpawnLaunchState,
+ spawnRuntimeAlive: effectiveSpawnRuntimeAlive,
+ spawnEntry,
runtimeEntry,
})
? reviewCandidate
@@ -995,12 +1029,16 @@ export const MemberList = memo(function MemberList({
runtimeSummary={buildRuntimeSummary(member, spawnEntry, runtimeEntry)}
runtimeEntry={runtimeEntry}
runtimeRunId={runtimeRunId}
- spawnStatus={spawnEntry?.status}
+ spawnStatus={effectiveSpawnStatus}
spawnEntry={spawnEntry}
- spawnError={spawnEntry?.error ?? spawnEntry?.hardFailureReason}
+ spawnError={
+ canPromoteBootstrapConfirmedVisualState
+ ? undefined
+ : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)
+ }
spawnLivenessSource={spawnEntry?.livenessSource}
- spawnLaunchState={spawnEntry?.launchState}
- spawnRuntimeAlive={spawnEntry?.runtimeAlive}
+ spawnLaunchState={effectiveSpawnLaunchState}
+ spawnRuntimeAlive={effectiveSpawnRuntimeAlive}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts
index a8fb62bb..3f34dd2d 100644
--- a/src/renderer/components/team/provisioningSteps.ts
+++ b/src/renderer/components/team/provisioningSteps.ts
@@ -1,4 +1,10 @@
import { isLeadMember } from '@shared/utils/leadDetection';
+import {
+ hasUnsafeProvisionedButNotAliveRuntimeEvidence,
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext,
+ isBootstrapConfirmedProvisionedButNotAliveFailure,
+ mentionsProcessTableUnavailable,
+} from '@shared/utils/teamLaunchFailureReason';
import type {
MemberSpawnStatusEntry,
@@ -80,6 +86,9 @@ function parseStatusUpdatedAtMs(value: string | undefined): number | null {
}
function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean {
+ if (isBootstrapConfirmedProvisionedButNotAliveFailure(entry)) {
+ return hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry);
+ }
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
}
@@ -92,13 +101,62 @@ function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolea
}
function isConfirmedSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
+ if (isBootstrapConfirmedProvisionedButNotAliveFailure(entry)) {
+ return !isFailedSpawnEntry(entry);
+ }
return entry.launchState === 'confirmed_alive' || entry.bootstrapConfirmed === true;
}
+function spawnEntryContradictsConfirmedJoin(entry: MemberSpawnStatusEntry): boolean {
+ if (!isConfirmedSpawnEntry(entry) || entry.runtimeAlive !== false) {
+ return false;
+ }
+ if (entry.runtimeDiagnosticSeverity === 'error') {
+ return true;
+ }
+ if (
+ entry.livenessKind === 'not_found' ||
+ entry.livenessKind === 'shell_only' ||
+ entry.livenessKind === 'permission_blocked' ||
+ entry.livenessKind === 'runtime_process_candidate'
+ ) {
+ return true;
+ }
+ const hasProcessTableUnavailableMarker =
+ mentionsProcessTableUnavailable(entry.runtimeDiagnostic) ||
+ mentionsProcessTableUnavailable(entry.hardFailureReason) ||
+ mentionsProcessTableUnavailable(entry.error);
+ if (!entry.livenessKind) {
+ return !hasProcessTableUnavailableMarker;
+ }
+ if (entry.livenessKind !== 'registered_only' && entry.livenessKind !== 'stale_metadata') {
+ return false;
+ }
+ return !hasProcessTableUnavailableMarker;
+}
+
function runtimeEntryContradictsConfirmedJoin(
+ entry: MemberSpawnStatusEntry,
runtimeEntry: TeamAgentRuntimeEntry | undefined
): boolean {
- return runtimeEntry?.alive === false;
+ if (runtimeEntry?.alive !== false || runtimeEntry.livenessKind === 'confirmed_bootstrap') {
+ return false;
+ }
+ if (
+ isBootstrapConfirmedProvisionedButNotAliveFailure(entry) &&
+ !hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry) &&
+ !hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(entry, runtimeEntry) &&
+ (runtimeEntry.livenessKind == null ||
+ runtimeEntry.livenessKind === 'registered_only' ||
+ runtimeEntry.livenessKind === 'stale_metadata') &&
+ (mentionsProcessTableUnavailable(runtimeEntry.runtimeDiagnostic) ||
+ mentionsProcessTableUnavailable(entry.runtimeDiagnostic) ||
+ mentionsProcessTableUnavailable(entry.hardFailureReason) ||
+ mentionsProcessTableUnavailable(entry.error))
+ ) {
+ return false;
+ }
+ return true;
}
function shouldPreferSnapshotEntryOverLive(
@@ -159,7 +217,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
continue;
}
observedTeammateCount += 1;
- if (entry.launchState === 'failed_to_start') {
+ if (isFailedSpawnEntry(entry)) {
failedSpawnCount += 1;
continue;
}
@@ -167,14 +225,21 @@ function summarizeLiveLaunchJoinMilestones(params: {
skippedSpawnCount += 1;
continue;
}
+ if (spawnEntryContradictsConfirmedJoin(entry)) {
+ pendingSpawnCount += 1;
+ continue;
+ }
if (
isConfirmedSpawnEntry(entry) &&
- runtimeEntryContradictsConfirmedJoin(getRuntimeEntry(params.memberRuntimeEntries, memberName))
+ runtimeEntryContradictsConfirmedJoin(
+ entry,
+ getRuntimeEntry(params.memberRuntimeEntries, memberName)
+ )
) {
pendingSpawnCount += 1;
continue;
}
- if (entry.launchState === 'confirmed_alive') {
+ if (isConfirmedSpawnEntry(entry)) {
heartbeatConfirmedCount += 1;
continue;
}
diff --git a/src/renderer/components/team/teamRuntimeDisplayRows.ts b/src/renderer/components/team/teamRuntimeDisplayRows.ts
index 0737cbf0..65367a15 100644
--- a/src/renderer/components/team/teamRuntimeDisplayRows.ts
+++ b/src/renderer/components/team/teamRuntimeDisplayRows.ts
@@ -1,3 +1,9 @@
+import {
+ hasUnsafeProvisionedButNotAliveRuntimeEvidence,
+ hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext,
+ isBootstrapConfirmedProvisionedButNotAliveFailure,
+} from '@shared/utils/teamLaunchFailureReason';
+
import type {
MemberSpawnStatusEntry,
TeamAgentRuntimeDiagnosticSeverity,
@@ -139,15 +145,29 @@ function buildRuntimeBackedDisplayRow(
spawn?: MemberSpawnStatusEntry
): TeamRuntimeDisplayRow {
const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error';
+ const bootstrapConfirmedProvisionedButNotAlive =
+ isBootstrapConfirmedProvisionedButNotAliveFailure(spawn);
const spawnDegradation = getSpawnDegradation(spawn);
+ const unsafeRuntimeEvidence = hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(
+ spawn,
+ runtime
+ );
+ const useBootstrapConfirmedState =
+ bootstrapConfirmedProvisionedButNotAlive &&
+ !hasErrorDiagnostic &&
+ !unsafeRuntimeEvidence &&
+ spawnDegradation == null;
const spawnStoppedEvidence = spawnDegradation ? null : getSpawnStoppedEvidence(runtime, spawn);
- const state = spawnStoppedEvidence
- ? 'stopped'
- : getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
+ const state = useBootstrapConfirmedState
+ ? 'running'
+ : spawnStoppedEvidence
+ ? 'stopped'
+ : getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
const degradedReason = spawnDegradation
? withLiveProcessContext(spawnDegradation.reason, runtime)
: undefined;
const stateReason =
+ (useBootstrapConfirmedState ? 'Bootstrap confirmed' : undefined) ??
degradedReason ??
spawnStoppedEvidence?.reason ??
runtime.runtimeDiagnostic ??
@@ -181,6 +201,17 @@ function buildRuntimeBackedDisplayRow(
function getSpawnDegradation(spawn?: MemberSpawnStatusEntry): SpawnDegradation | null {
if (!spawn) return null;
+ if (isBootstrapConfirmedProvisionedButNotAliveFailure(spawn)) {
+ if (!hasUnsafeProvisionedButNotAliveRuntimeEvidence(spawn)) {
+ return null;
+ }
+ const reason = spawn.runtimeDiagnostic ?? 'Runtime launch status needs attention';
+ return {
+ reason,
+ diagnostic: spawn.runtimeDiagnostic ?? reason,
+ diagnosticSeverity: spawn.runtimeDiagnosticSeverity === 'error' ? 'error' : 'warning',
+ };
+ }
if (spawn.status === 'error' || spawn.hardFailure === true) {
const reason =
@@ -226,7 +257,10 @@ function getSpawnStoppedEvidence(
runtime: TeamAgentRuntimeEntry,
spawn?: MemberSpawnStatusEntry
): SpawnStoppedEvidence | null {
- if (!spawn || spawn.runtimeAlive !== false || runtime.livenessKind !== 'confirmed_bootstrap') {
+ if (isBootstrapConfirmedProvisionedButNotAliveFailure(spawn)) {
+ return null;
+ }
+ if (spawn?.runtimeAlive !== false || runtime.livenessKind !== 'confirmed_bootstrap') {
return null;
}
if (spawn.status !== 'online' && spawn.launchState !== 'confirmed_alive') {
@@ -267,6 +301,23 @@ function buildSpawnBackedDisplayRow(
memberName: string,
spawn: MemberSpawnStatusEntry
): TeamRuntimeDisplayRow {
+ if (
+ isBootstrapConfirmedProvisionedButNotAliveFailure(spawn) &&
+ !hasUnsafeProvisionedButNotAliveRuntimeEvidence(spawn)
+ ) {
+ return {
+ memberName,
+ state: 'running',
+ stateReason: 'Bootstrap confirmed',
+ source: 'spawn-status',
+ updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt,
+ runtimeModel: spawn.runtimeModel,
+ diagnostic: spawn.runtimeDiagnostic,
+ diagnosticSeverity: spawn.runtimeDiagnosticSeverity,
+ actionsAllowed: false,
+ };
+ }
+
const spawnDegradation = getSpawnDegradation(spawn);
if (spawnDegradation) {
return {
@@ -359,6 +410,7 @@ function buildSpawnBackedDisplayRow(
}
function getSpawnOnlyStoppedEvidence(spawn: MemberSpawnStatusEntry): SpawnStoppedEvidence | null {
+ if (isBootstrapConfirmedProvisionedButNotAliveFailure(spawn)) return null;
if (spawn.runtimeAlive !== false) return null;
if (spawn.status !== 'online' && spawn.launchState !== 'confirmed_alive') return null;
diff --git a/src/renderer/components/terminal/EmbeddedTerminal.test.tsx b/src/renderer/components/terminal/EmbeddedTerminal.test.tsx
new file mode 100644
index 00000000..3abebcd7
--- /dev/null
+++ b/src/renderer/components/terminal/EmbeddedTerminal.test.tsx
@@ -0,0 +1,172 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { api } from '@renderer/api';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { EmbeddedTerminal } from './EmbeddedTerminal';
+
+vi.mock('@renderer/api', () => ({
+ api: {
+ openExternal: vi.fn(),
+ terminal: {
+ spawn: vi.fn(),
+ write: vi.fn(),
+ resize: vi.fn(),
+ kill: vi.fn(),
+ onData: vi.fn(),
+ onExit: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('@xterm/xterm', () => ({
+ Terminal: vi.fn().mockImplementation(() => ({
+ cols: 80,
+ rows: 24,
+ loadAddon: vi.fn(),
+ open: vi.fn(),
+ attachCustomKeyEventHandler: vi.fn(),
+ onData: vi.fn(() => ({ dispose: vi.fn() })),
+ getSelection: vi.fn(() => ''),
+ write: vi.fn(),
+ dispose: vi.fn(),
+ })),
+}));
+
+vi.mock('@xterm/addon-fit', () => ({
+ FitAddon: vi.fn().mockImplementation(() => ({
+ fit: vi.fn(),
+ })),
+}));
+
+vi.mock('@xterm/addon-web-links', () => ({
+ WebLinksAddon: vi.fn().mockImplementation(() => ({})),
+}));
+
+let frameCallbacks: Map
;
+let nextFrameId: number;
+
+function flushFrames(): void {
+ const callbacks = Array.from(frameCallbacks.values());
+ frameCallbacks.clear();
+ callbacks.forEach((callback) => callback(performance.now()));
+}
+
+async function flushReact(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('EmbeddedTerminal', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+
+ frameCallbacks = new Map();
+ nextFrameId = 1;
+ vi.stubGlobal(
+ 'requestAnimationFrame',
+ vi.fn((callback: FrameRequestCallback) => {
+ const id = nextFrameId;
+ nextFrameId += 1;
+ frameCallbacks.set(id, callback);
+ return id;
+ })
+ );
+ vi.stubGlobal(
+ 'cancelAnimationFrame',
+ vi.fn((id: number) => {
+ frameCallbacks.delete(id);
+ })
+ );
+ vi.stubGlobal(
+ 'ResizeObserver',
+ vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ disconnect: vi.fn(),
+ }))
+ );
+
+ vi.mocked(api.terminal.spawn).mockResolvedValue('pty-1');
+ vi.mocked(api.terminal.onData).mockReturnValue(() => undefined);
+ vi.mocked(api.terminal.onExit).mockReturnValue(() => undefined);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('does not spawn twice during React StrictMode effect replay', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+
+
+
+ );
+ await flushReact();
+ });
+
+ expect(api.terminal.spawn).not.toHaveBeenCalled();
+
+ await act(async () => {
+ flushFrames();
+ await flushReact();
+ });
+
+ expect(api.terminal.spawn).toHaveBeenCalledTimes(1);
+ expect(api.terminal.spawn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: '/bin/runtime',
+ args: ['auth', 'login'],
+ })
+ );
+
+ await act(async () => {
+ root.unmount();
+ await flushReact();
+ });
+ });
+
+ it('kills a PTY that resolves after the terminal unmounts', async () => {
+ let resolveSpawn: (id: string) => void = () => {};
+ vi.mocked(api.terminal.spawn).mockReturnValue(
+ new Promise((resolve) => {
+ resolveSpawn = resolve;
+ })
+ );
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render();
+ await flushReact();
+ });
+
+ await act(async () => {
+ flushFrames();
+ await flushReact();
+ });
+
+ expect(api.terminal.spawn).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ root.unmount();
+ await flushReact();
+ });
+
+ await act(async () => {
+ resolveSpawn('late-pty');
+ await flushReact();
+ });
+
+ expect(api.terminal.kill).toHaveBeenCalledWith('late-pty');
+ });
+});
diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx
index 78acc77d..3e39c938 100644
--- a/src/renderer/components/terminal/EmbeddedTerminal.tsx
+++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx
@@ -64,8 +64,43 @@ export const EmbeddedTerminal = ({
term.open(container);
- // Fit after opening so dimensions are correct
- const rafId = requestAnimationFrame(() => fitAddon.fit());
+ const spawnTerminal = (): void => {
+ if (disposed) return;
+
+ const spawnOptions: PtySpawnOptions = {
+ ...(command ? { command } : {}),
+ ...(args ? { args } : {}),
+ ...(cwd ? { cwd } : {}),
+ ...(env ? { env } : {}),
+ cols: term.cols,
+ rows: term.rows,
+ };
+
+ api.terminal
+ .spawn(spawnOptions)
+ .then((id) => {
+ if (disposed) {
+ api.terminal.kill(id);
+ return;
+ }
+ ptyId = id;
+ // Send actual terminal size after spawn (fitAddon.fit() may have changed cols/rows).
+ api.terminal.resize(id, term.cols, term.rows);
+ })
+ .catch((err: unknown) => {
+ if (disposed) return;
+ term.write(
+ `\r\n\x1b[31mFailed to start terminal: ${err instanceof Error ? err.message : String(err)}\x1b[0m\r\n`
+ );
+ });
+ };
+
+ // Defer spawning until after the first frame. React StrictMode replays effects
+ // in development; canceling this RAF prevents duplicate one-shot commands.
+ const rafId = requestAnimationFrame(() => {
+ fitAddon.fit();
+ spawnTerminal();
+ });
// Ctrl+C with selection → copy to clipboard (instead of sending SIGINT)
term.attachCustomKeyEventHandler((event) => {
@@ -97,32 +132,6 @@ export const EmbeddedTerminal = ({
}
});
- // Spawn PTY
- const spawnOptions: PtySpawnOptions = {
- ...(command ? { command } : {}),
- ...(args ? { args } : {}),
- ...(cwd ? { cwd } : {}),
- ...(env ? { env } : {}),
- cols: term.cols,
- rows: term.rows,
- };
-
- api.terminal
- .spawn(spawnOptions)
- .then((id) => {
- if (disposed) return;
- ptyId = id;
- // Send actual terminal size after spawn (fitAddon.fit() may have
- // changed cols/rows via RAF after spawnOptions was constructed)
- api.terminal.resize(id, term.cols, term.rows);
- })
- .catch((err: unknown) => {
- if (disposed) return;
- term.write(
- `\r\n\x1b[31mFailed to start terminal: ${err instanceof Error ? err.message : String(err)}\x1b[0m\r\n`
- );
- });
-
// ResizeObserver → fitAddon.fit() → pty.resize()
const observer = new ResizeObserver(() => {
fitAddon.fit();
diff --git a/src/renderer/components/ui/ChipInteractionLayer.tsx b/src/renderer/components/ui/ChipInteractionLayer.tsx
index 521c1365..173ed897 100644
--- a/src/renderer/components/ui/ChipInteractionLayer.tsx
+++ b/src/renderer/components/ui/ChipInteractionLayer.tsx
@@ -171,6 +171,34 @@ interface ChipInteractionLayerProps {
onRemove: (chipId: string) => void;
}
+function areChipsEquivalent(a: InlineChip, b: InlineChip): boolean {
+ return (
+ a.id === b.id &&
+ a.filePath === b.filePath &&
+ a.fileName === b.fileName &&
+ a.fromLine === b.fromLine &&
+ a.toLine === b.toLine &&
+ a.codeText === b.codeText &&
+ a.displayPath === b.displayPath &&
+ a.isFolder === b.isFolder
+ );
+}
+
+function areChipPositionsEquivalent(current: ChipPosition[], next: ChipPosition[]): boolean {
+ if (current.length !== next.length) return false;
+
+ return current.every((position, index) => {
+ const nextPosition = next[index];
+ return (
+ position.top === nextPosition.top &&
+ position.left === nextPosition.left &&
+ position.width === nextPosition.width &&
+ position.height === nextPosition.height &&
+ areChipsEquivalent(position.chip, nextPosition.chip)
+ );
+ });
+}
+
export const ChipInteractionLayer = ({
chips,
value,
@@ -179,18 +207,25 @@ export const ChipInteractionLayer = ({
onRemove,
}: ChipInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState([]);
+ const positionsRef = React.useRef([]);
const revealFileInEditor = useStore((s) => s.revealFileInEditor);
const revealFolderInEditor = useStore((s) => s.revealFolderInEditor);
+ const commitPositions = React.useCallback((nextPositions: ChipPosition[]) => {
+ if (areChipPositionsEquivalent(positionsRef.current, nextPositions)) return;
+ positionsRef.current = nextPositions;
+ setPositions(nextPositions);
+ }, []);
+
React.useLayoutEffect(() => {
if (chips.length === 0) {
- setPositions([]);
+ commitPositions([]);
return;
}
const textarea = textareaRef.current;
if (!textarea) return;
- setPositions(calculateChipPositions(textarea, value, chips));
- }, [chips, value, textareaRef]);
+ commitPositions(calculateChipPositions(textarea, value, chips));
+ }, [chips, commitPositions, value, textareaRef]);
if (positions.length === 0) return null;
@@ -200,6 +235,14 @@ export const ChipInteractionLayer = ({
{positions.map((pos) => {
const isFileChip = pos.chip.fromLine == null;
const isFolderChip = pos.chip.isFolder === true;
+ const openChipTarget = (): void => {
+ if (isFolderChip) {
+ revealFolderInEditor(pos.chip.filePath);
+ } else {
+ revealFileInEditor(pos.chip.filePath);
+ }
+ };
+
return (
@@ -211,20 +254,19 @@ export const ChipInteractionLayer = ({
width: pos.width,
height: pos.height,
}}
- onClick={
- isFileChip
- ? (e) => {
- e.preventDefault();
- e.stopPropagation();
- if (isFolderChip) {
- revealFolderInEditor(pos.chip.filePath);
- } else {
- revealFileInEditor(pos.chip.filePath);
- }
- }
- : undefined
- }
>
+ {isFileChip ? (
+