+ {team.skippedMembers?.length
+ ? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.`
+ : 'Last launch has skipped teammates.'}
+
) : null}
{team.members && team.members.length > 0 ? (
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 47c7abc1..4f03e023 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -20,7 +20,16 @@ import {
} from '@renderer/utils/memberLaunchDiagnostics';
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
-import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
+import { isLeadMember } from '@shared/utils/leadDetection';
+import {
+ AlertTriangle,
+ Ban,
+ GitBranch,
+ Loader2,
+ MessageSquare,
+ Plus,
+ RotateCcw,
+} from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
@@ -63,6 +72,8 @@ interface MemberCardProps {
onClick?: () => void;
onSendMessage?: () => void;
onAssignTask?: () => void;
+ onRestartMember?: (memberName: string) => Promise
| void;
+ onSkipMemberForLaunch?: (memberName: string) => Promise | void;
}
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
@@ -111,6 +122,8 @@ export const MemberCard = ({
onClick,
onSendMessage,
onAssignTask,
+ onRestartMember,
+ onSkipMemberForLaunch,
}: MemberCardProps): React.JSX.Element => {
// NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
@@ -118,6 +131,10 @@ export const MemberCard = ({
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const selectedTeamName = useStore((s) => s.selectedTeamName);
+ const [retryingLaunch, setRetryingLaunch] = useState(false);
+ const [retryLaunchError, setRetryLaunchError] = useState(null);
+ const [skippingLaunch, setSkippingLaunch] = useState(false);
+ const [skipLaunchError, setSkipLaunchError] = useState(null);
const teamMembers = useStore((s) =>
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
@@ -214,12 +231,68 @@ export const MemberCard = ({
!isRemoved &&
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
+ const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
+ const isSkippedLaunch =
+ spawnStatus === 'skipped' ||
+ spawnLaunchState === 'skipped_for_launch' ||
+ spawnEntry?.skippedForLaunch === true;
+ const showFailedLaunchBadge = !isRemoved && isFailedLaunch;
+ const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch;
+ const hasLiveLaunchControls =
+ isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
+ const canRetryLaunch =
+ (showFailedLaunchBadge || showSkippedLaunchBadge) &&
+ !isLeadMember(member) &&
+ Boolean(onRestartMember) &&
+ hasLiveLaunchControls;
+ const canSkipFailedLaunch =
+ showFailedLaunchBadge &&
+ !isLeadMember(member) &&
+ Boolean(onSkipMemberForLaunch) &&
+ hasLiveLaunchControls;
const showRuntimeAdvisoryBadge =
!isRemoved &&
Boolean(runtimeAdvisoryLabel) &&
!showLaunchBadge &&
- spawnStatus !== 'error' &&
+ !isFailedLaunch &&
+ !isSkippedLaunch &&
(Boolean(activityTask) || !isAwaitingReply);
+ const handleRetryFailedLaunch = async (
+ event: React.MouseEvent
+ ): Promise => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!onRestartMember || retryingLaunch) {
+ return;
+ }
+ setRetryLaunchError(null);
+ setRetryingLaunch(true);
+ try {
+ await onRestartMember(member.name);
+ } catch (error) {
+ setRetryLaunchError(error instanceof Error ? error.message : 'Failed to retry teammate');
+ } finally {
+ setRetryingLaunch(false);
+ }
+ };
+ const handleSkipFailedLaunch = async (
+ event: React.MouseEvent
+ ): Promise => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!onSkipMemberForLaunch || skippingLaunch) {
+ return;
+ }
+ setSkipLaunchError(null);
+ setSkippingLaunch(true);
+ try {
+ await onSkipMemberForLaunch(member.name);
+ } catch (error) {
+ setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate');
+ } finally {
+ setSkippingLaunch(false);
+ }
+ };
return (
- ) : spawnStatus === 'error' ? (
+ ) : showFailedLaunchBadge ? (
@@ -382,6 +455,94 @@ export const MemberCard = ({
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
+ {canSkipFailedLaunch ? (
+
+
+
+
+
+ {skipLaunchError ??
+ (skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')}
+
+
+ ) : null}
+ {canRetryLaunch ? (
+
+
+
+
+
+ {retryLaunchError ??
+ (retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
+
+
+ ) : null}
+
+ ) : showSkippedLaunchBadge ? (
+
+
+
+
+
+
+ {displayPresenceLabel}
+
+
+
+
+ {spawnEntry?.skipReason ?? 'Skipped for this launch'}
+
+
+ {canRetryLaunch ? (
+
+
+
+
+
+ {retryLaunchError ??
+ (retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
+
+
+ ) : null}
) : showRuntimeAdvisoryBadge ? (
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx
index 09e07a21..8b3f6140 100644
--- a/src/renderer/components/team/members/MemberList.tsx
+++ b/src/renderer/components/team/members/MemberList.tsx
@@ -33,6 +33,8 @@ interface MemberListProps {
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
onOpenTask?: (taskId: string) => void;
+ onRestartMember?: (memberName: string) => Promise | void;
+ onSkipMemberForLaunch?: (memberName: string) => Promise | void;
}
function areResolvedMembersEquivalent(
@@ -151,6 +153,9 @@ function areMemberSpawnStatusesEquivalent(
leftEntry.error !== rightEntry.error ||
leftEntry.hardFailure !== rightEntry.hardFailure ||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
+ leftEntry.skippedForLaunch !== rightEntry.skippedForLaunch ||
+ leftEntry.skipReason !== rightEntry.skipReason ||
+ leftEntry.skippedAt !== rightEntry.skippedAt ||
leftEntry.livenessSource !== rightEntry.livenessSource ||
leftEntry.livenessKind !== rightEntry.livenessKind ||
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
@@ -244,6 +249,8 @@ function areMemberListPropsEqual(
prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning &&
prev.leadActivity === next.leadActivity &&
+ prev.onRestartMember === next.onRestartMember &&
+ prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
);
}
@@ -265,6 +272,8 @@ export const MemberList = memo(function MemberList({
onSendMessage,
onAssignTask,
onOpenTask,
+ onRestartMember,
+ onSkipMemberForLaunch,
}: MemberListProps): React.JSX.Element {
const containerRef = useRef(null);
const [isWide, setIsWide] = useState(false);
@@ -372,6 +381,8 @@ export const MemberList = memo(function MemberList({
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
+ onRestartMember={isRemoved ? undefined : onRestartMember}
+ onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch}
/>
);
};
diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts
index 0268548c..5d1e1d8f 100644
--- a/src/renderer/components/team/provisioningSteps.ts
+++ b/src/renderer/components/team/provisioningSteps.ts
@@ -27,6 +27,7 @@ export interface LaunchJoinMilestones {
processOnlyAliveCount: number;
pendingSpawnCount: number;
failedSpawnCount: number;
+ skippedSpawnCount: number;
}
type DisplayStepMilestones = LaunchJoinMilestones & {
@@ -106,6 +107,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
let processOnlyAliveCount = 0;
let pendingSpawnCount = 0;
let failedSpawnCount = 0;
+ let skippedSpawnCount = 0;
let observedTeammateCount = 0;
for (const memberName of teammateNames) {
@@ -127,6 +129,10 @@ function summarizeLiveLaunchJoinMilestones(params: {
failedSpawnCount += 1;
continue;
}
+ if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
+ skippedSpawnCount += 1;
+ continue;
+ }
if (entry.launchState === 'confirmed_alive') {
heartbeatConfirmedCount += 1;
continue;
@@ -153,6 +159,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
observedTeammateCount,
};
}
@@ -208,19 +215,23 @@ export function getLaunchJoinMilestonesFromMembers({
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
failedSpawnCount: snapshotSummary.failedCount,
+ skippedSpawnCount: snapshotSummary.skippedCount ?? 0,
};
const snapshotAccountedFor =
snapshotMilestones.heartbeatConfirmedCount +
snapshotMilestones.processOnlyAliveCount +
- snapshotMilestones.failedSpawnCount;
+ snapshotMilestones.failedSpawnCount +
+ snapshotMilestones.skippedSpawnCount;
const liveAccountedFor =
liveSummary.heartbeatConfirmedCount +
liveSummary.processOnlyAliveCount +
- liveSummary.failedSpawnCount;
+ liveSummary.failedSpawnCount +
+ liveSummary.skippedSpawnCount;
const liveSummaryIsMoreAdvanced =
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
+ liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount ||
liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount ||
(snapshotMilestones.failedSpawnCount === 0 &&
@@ -248,6 +259,7 @@ export function getLaunchJoinState({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
}: LaunchJoinMilestones): {
allTeammatesConfirmedAlive: boolean;
hasMembersStillJoining: boolean;
@@ -256,14 +268,16 @@ export function getLaunchJoinState({
const allTeammatesConfirmedAlive =
expectedTeammateCount > 0 &&
failedSpawnCount === 0 &&
+ skippedSpawnCount === 0 &&
heartbeatConfirmedCount >= expectedTeammateCount;
const remainingJoinCount =
- expectedTeammateCount > 0 && failedSpawnCount === 0
+ expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
: 0;
const hasMembersStillJoining =
expectedTeammateCount > 0 &&
failedSpawnCount === 0 &&
+ skippedSpawnCount === 0 &&
remainingJoinCount > 0 &&
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
@@ -295,6 +309,7 @@ export function getDisplayStepIndex({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
}: DisplayStepMilestones): number {
switch (progress.state) {
case 'ready':
@@ -322,8 +337,12 @@ export function getDisplayStepIndex({
if (failedSpawnCount > 0) {
return 2;
}
+ if (skippedSpawnCount > 0) {
+ return 2;
+ }
- const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
+ const accountedForTeammates =
+ heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount;
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
return 2;
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 030ac9b2..772cf7a1 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -71,14 +71,7 @@ const logger = createLogger('teamSlice');
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
const TEAM_FETCH_TIMEOUT_MS = 30_000;
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
-const TEAM_DATA_IPC_WARN_MS = 350;
-const TEAM_DATA_SET_WARN_MS = 12;
-const TEAM_DATA_POST_WARN_MS = 24;
-const TEAM_DATA_LARGE_MESSAGES = 150;
-const TEAM_DATA_LARGE_TASKS = 80;
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
-const TEAM_REFRESH_BURST_WARN_COUNT = 5;
-const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000;
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
const inFlightTeamDataRequests = new Map>();
const inFlightRefreshTeamDataCalls = new Map>();
@@ -567,45 +560,6 @@ function fetchTeamDataFresh(teamName: string): Promise {
);
}
-function summarizeTeamDataCounts(data: TeamViewSnapshot | null | undefined): {
- tasks: number;
- members: number;
- activeMembers: number;
- processes: number;
-} {
- if (!data) {
- return { tasks: 0, members: 0, activeMembers: 0, processes: 0 };
- }
-
- return {
- tasks: data.tasks.length,
- members: data.members.length,
- activeMembers: data.members.filter((member) => !member.removedAt).length,
- processes: data.processes.length,
- };
-}
-
-function estimateTeamPayloadWeight(data: TeamViewSnapshot): {
- taskComments: number;
- taskHistoryEvents: number;
- taskDescriptionChars: number;
-} {
- let taskComments = 0;
- let taskHistoryEvents = 0;
- let taskDescriptionChars = 0;
- for (const task of data.tasks) {
- taskComments += task.comments?.length ?? 0;
- taskHistoryEvents += task.historyEvents?.length ?? 0;
- taskDescriptionChars += task.description?.length ?? 0;
- }
-
- return {
- taskComments,
- taskHistoryEvents,
- taskDescriptionChars,
- };
-}
-
function noteTeamRefreshBurst(teamName: string): number {
const now = Date.now();
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
@@ -621,77 +575,10 @@ function noteTeamRefreshBurst(teamName: string): number {
diagnostic.count += 1;
- if (
- diagnostic.count >= TEAM_REFRESH_BURST_WARN_COUNT &&
- now - diagnostic.lastWarnAt >= TEAM_REFRESH_WARN_THROTTLE_MS
- ) {
- diagnostic.lastWarnAt = now;
- logger.warn(
- `[perf] refreshTeamData burst team=${teamName} count=${diagnostic.count} windowMs=${
- now - diagnostic.windowStartedAt
- }`
- );
- }
-
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
return diagnostic.count;
}
-function maybeLogTeamDataPerf(params: {
- phase: 'selectTeam' | 'refreshTeamData';
- teamName: string;
- ipcMs: number;
- setMs: number;
- postMs: number;
- totalMs: number;
- previousData: TeamViewSnapshot | null | undefined;
- nextData: TeamViewSnapshot;
- deduped: boolean;
- reusedInFlightRequest: boolean;
- burstCount?: number;
-}): void {
- const {
- phase,
- teamName,
- ipcMs,
- setMs,
- postMs,
- totalMs,
- previousData,
- nextData,
- deduped,
- reusedInFlightRequest,
- burstCount,
- } = params;
-
- const nextCounts = summarizeTeamDataCounts(nextData);
- const previousCounts = summarizeTeamDataCounts(previousData);
- const largePayload = nextCounts.tasks >= TEAM_DATA_LARGE_TASKS;
- const slow =
- ipcMs >= TEAM_DATA_IPC_WARN_MS ||
- setMs >= TEAM_DATA_SET_WARN_MS ||
- postMs >= TEAM_DATA_POST_WARN_MS;
-
- if (!slow && !largePayload && !reusedInFlightRequest) {
- return;
- }
-
- const payloadWeight = estimateTeamPayloadWeight(nextData);
- logger.warn(
- `[perf] ${phase} team=${teamName} ipc=${ipcMs.toFixed(1)}ms set=${setMs.toFixed(
- 1
- )}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${
- reusedInFlightRequest ? 'yes' : 'no'
- } burst=${burstCount ?? 1} counts=tasks:${previousCounts.tasks}->${nextCounts.tasks},members:${
- previousCounts.members
- }->${nextCounts.members},activeMembers:${
- previousCounts.activeMembers
- }->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${
- payloadWeight.taskDescriptionChars
- },taskComments=${payloadWeight.taskComments},historyEvents=${payloadWeight.taskHistoryEvents}`
- );
-}
-
function areLaunchSummaryCountsEqual(
left: PersistedTeamLaunchSummary | undefined,
right: PersistedTeamLaunchSummary | undefined
@@ -702,6 +589,7 @@ function areLaunchSummaryCountsEqual(
left.confirmedCount === right.confirmedCount &&
left.pendingCount === right.pendingCount &&
left.failedCount === right.failedCount &&
+ left.skippedCount === right.skippedCount &&
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
@@ -741,6 +629,9 @@ function areMemberSpawnStatusEntriesEqual(
left.launchState === right.launchState &&
left.error === right.error &&
left.hardFailureReason === right.hardFailureReason &&
+ left.skippedForLaunch === right.skippedForLaunch &&
+ left.skipReason === right.skipReason &&
+ left.skippedAt === right.skippedAt &&
left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive &&
left.runtimeModel === right.runtimeModel &&
@@ -2158,6 +2049,7 @@ export interface TeamSlice {
) => Promise;
addMember: (teamName: string, request: AddMemberRequest) => Promise;
restartMember: (teamName: string, memberName: string) => Promise;
+ skipMemberForLaunch: (teamName: string, memberName: string) => Promise;
removeMember: (teamName: string, memberName: string) => Promise;
updateMemberRole: (
teamName: string,
@@ -3234,7 +3126,6 @@ export const createTeamSlice: StateCreator = (set,
},
selectTeam: async (teamName: string, opts) => {
- const startedAt = performance.now();
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
// Guard: prevent duplicate in-flight fetches for the same team.
@@ -3267,7 +3158,6 @@ export const createTeamSlice: StateCreator = (set,
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
- const ipcMs = performance.now() - startedAt;
// Stale check: user may have switched to another team during the async call
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
return;
@@ -3299,7 +3189,6 @@ export const createTeamSlice: StateCreator = (set,
}
: data;
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
- const setStartedAt = performance.now();
set((state) => {
const nextCache =
state.teamDataCacheByName[teamName] === nextTeamData
@@ -3318,8 +3207,6 @@ export const createTeamSlice: StateCreator = (set,
};
});
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
- const setMs = performance.now() - setStartedAt;
- const postStartedAt = performance.now();
const invalidationState = previousData
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
: { cacheKeys: [], taskIds: [] };
@@ -3329,19 +3216,6 @@ export const createTeamSlice: StateCreator = (set,
if (invalidationState.taskIds.length > 0) {
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
}
- const postMs = performance.now() - postStartedAt;
- maybeLogTeamDataPerf({
- phase: 'selectTeam',
- teamName,
- ipcMs,
- setMs,
- postMs,
- totalMs: performance.now() - startedAt,
- previousData,
- nextData: nextTeamData,
- deduped: true,
- reusedInFlightRequest: false,
- });
// Sync tab label with the team's display name from config
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
@@ -3445,19 +3319,15 @@ export const createTeamSlice: StateCreator = (set,
},
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
- const startedAt = performance.now();
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
const refreshToken = beginInFlightTeamDataRefresh(teamName);
// Silent refresh — update data without showing loading skeleton.
// Only selectTeam() sets loading: true (for initial load).
const reusedInFlightRequest =
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
- const burstCount = noteTeamRefreshBurst(teamName);
+ noteTeamRefreshBurst(teamName);
if (reusedInFlightRequest) {
pendingFreshTeamDataRefreshes.add(teamName);
- logger.warn(
- `[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup`
- );
}
try {
const previousData = selectTeamDataForName(get(), teamName);
@@ -3467,7 +3337,6 @@ export const createTeamSlice: StateCreator = (set,
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
- const ipcMs = performance.now() - startedAt;
const projectedTeamData = previousData
? {
...data,
@@ -3475,7 +3344,6 @@ export const createTeamSlice: StateCreator = (set,
}
: data;
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
- const setStartedAt = performance.now();
set((state) => {
const nextCache =
state.teamDataCacheByName[teamName] === nextTeamData
@@ -3507,8 +3375,6 @@ export const createTeamSlice: StateCreator = (set,
};
});
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
- const setMs = performance.now() - setStartedAt;
- const postStartedAt = performance.now();
const invalidationState = previousData
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
: { cacheKeys: [], taskIds: [] };
@@ -3518,20 +3384,6 @@ export const createTeamSlice: StateCreator = (set,
if (invalidationState.taskIds.length > 0) {
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
}
- const postMs = performance.now() - postStartedAt;
- maybeLogTeamDataPerf({
- phase: 'refreshTeamData',
- teamName,
- ipcMs,
- setMs,
- postMs,
- totalMs: performance.now() - startedAt,
- previousData,
- nextData: nextTeamData,
- deduped: opts?.withDedup === true,
- reusedInFlightRequest,
- burstCount,
- });
} catch (error) {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
@@ -4224,6 +4076,20 @@ export const createTeamSlice: StateCreator = (set,
}
},
+ skipMemberForLaunch: async (teamName: string, memberName: string) => {
+ try {
+ await unwrapIpc('team:skipMemberForLaunch', () =>
+ api.teams.skipMemberForLaunch(teamName, memberName)
+ );
+ } finally {
+ await Promise.allSettled([
+ get().fetchMemberSpawnStatuses(teamName),
+ get().fetchTeamAgentRuntime(teamName),
+ get().fetchTeams(),
+ ]);
+ }
+ },
+
removeMember: async (teamName: string, memberName: string) => {
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
await get().refreshTeamData(teamName);
diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts
index ac4f6def..555233f9 100644
--- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts
+++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
- CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
getAvailableTeamProviderModelOptions,
getAvailableTeamProviderModels,
getTeamModelSelectionError,
@@ -151,7 +150,7 @@ describe('team model availability Codex catalog integration', () => {
]);
});
- it('shows app-server future models but blocks launch until runtime declares dynamic support', () => {
+ it('allows app-server catalog models even when the runtime does not declare dynamic model launch', () => {
const providerStatus = createCodexProviderStatus([
{
id: 'gpt-5.5',
@@ -168,16 +167,14 @@ describe('team model availability Codex catalog integration', () => {
},
]);
- expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([]);
+ expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.5']);
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
value: 'gpt-5.5',
label: '5.5',
badgeLabel: 'New',
- availabilityStatus: null,
+ availabilityStatus: 'available',
});
- expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain(
- CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON
- );
+ expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
});
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts
index c21db4e4..2af7729f 100644
--- a/src/renderer/utils/memberHelpers.ts
+++ b/src/renderer/utils/memberHelpers.ts
@@ -119,6 +119,7 @@ export const SPAWN_DOT_COLORS: Record = {
spawning: 'bg-amber-400',
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
error: 'bg-red-400',
+ skipped: 'bg-zinc-500',
};
export const SPAWN_PRESENCE_LABELS: Record = {
@@ -127,6 +128,7 @@ export const SPAWN_PRESENCE_LABELS: Record = {
spawning: 'starting',
online: 'ready',
error: 'spawn failed',
+ skipped: 'skipped',
};
function isLaunchStillStarting(
@@ -138,6 +140,9 @@ function isLaunchStillStarting(
if (spawnLaunchState === 'failed_to_start') {
return false;
}
+ if (spawnLaunchState === 'skipped_for_launch') {
+ return false;
+ }
if (spawnLaunchState === 'runtime_pending_permission') {
return false;
}
@@ -171,6 +176,9 @@ export function getSpawnAwareDotClass(
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_DOT_COLORS.error;
}
+ if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
+ return SPAWN_DOT_COLORS.skipped;
+ }
if (spawnLaunchState === 'runtime_pending_permission') {
return 'bg-amber-400 animate-pulse';
}
@@ -218,6 +226,9 @@ export function getSpawnAwarePresenceLabel(
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_PRESENCE_LABELS.error;
}
+ if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
+ return SPAWN_PRESENCE_LABELS.skipped;
+ }
if (spawnLaunchState === 'runtime_pending_permission') {
return 'connecting';
}
@@ -259,6 +270,9 @@ export function getSpawnCardClass(
) {
return 'member-waiting-shimmer';
}
+ if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
+ return 'opacity-70';
+ }
if (spawnLaunchState === 'runtime_pending_permission') {
return 'member-waiting-shimmer';
}
@@ -518,6 +532,7 @@ export function getLaunchAwarePresenceLabel(
basePresenceLabel === 'starting' ||
basePresenceLabel === 'connecting' ||
basePresenceLabel === 'spawn failed' ||
+ basePresenceLabel === 'skipped' ||
basePresenceLabel === 'offline' ||
basePresenceLabel === 'terminated'
) {
@@ -538,6 +553,7 @@ export type MemberLaunchVisualState =
| 'stale_runtime'
| 'settling'
| 'error'
+ | 'skipped'
| null;
export interface MemberLaunchPresentation {
@@ -574,6 +590,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
return 'joining team';
case 'error':
return 'failed';
+ case 'skipped':
+ return 'skipped';
default:
return null;
}
@@ -643,6 +661,8 @@ export function buildMemberLaunchPresentation({
if (isTeamAlive !== false || isTeamProvisioning) {
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
launchVisualState = 'error';
+ } else if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
+ launchVisualState = 'skipped';
} else if (spawnLaunchState === 'runtime_pending_permission') {
launchVisualState = 'permission_pending';
} else if (runtimeEntry?.livenessKind === 'shell_only') {
diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts
index ddd09c16..fd944ac1 100644
--- a/src/renderer/utils/teamModelAvailability.ts
+++ b/src/renderer/utils/teamModelAvailability.ts
@@ -23,7 +23,6 @@ import type {
} from '@shared/types';
export {
- CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts
index e951b4f3..da480b2c 100644
--- a/src/renderer/utils/teamModelCatalog.ts
+++ b/src/renderer/utils/teamModelCatalog.ts
@@ -21,7 +21,7 @@ export {
type SupportedProviderId = CliProviderId | TeamProviderId;
type RuntimeAwareProviderStatus = Pick<
CliProviderStatus,
- 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities'
+ 'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
>;
export interface TeamProviderModelOption {
@@ -40,8 +40,6 @@ export const GPT_5_2_CODEX_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.';
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.';
-export const CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON =
- 'Available in Codex, waiting for Agent Teams runtime support.';
const TEAM_PROVIDER_LABELS: Record = {
anthropic: 'Anthropic',
@@ -166,13 +164,6 @@ function getKnownTeamProviderModelOption(
return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed);
}
-function isKnownTeamProviderModel(
- providerId: SupportedProviderId | undefined,
- model: string | undefined
-): boolean {
- return Boolean(getKnownTeamProviderModelOption(providerId, model));
-}
-
export function getTeamProviderModelOptions(
providerId: SupportedProviderId
): readonly TeamProviderModelOption[] {
@@ -471,18 +462,6 @@ export function getRuntimeAwareTeamModelUiDisabledReason(
return null;
}
- if (
- providerId === 'codex' &&
- providerStatus?.modelCatalog?.providerId === 'codex' &&
- providerStatus.modelCatalog.models.some(
- (item) => item.launchModel === trimmed || item.id === trimmed
- ) &&
- !isKnownTeamProviderModel(providerId, trimmed) &&
- providerStatus.runtimeCapabilities?.modelCatalog?.dynamic !== true
- ) {
- return CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON;
- }
-
return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus)
? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON
: null;
diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts
index 5bc78b84..ee4c6fda 100644
--- a/src/renderer/utils/teamProvisioningPresentation.ts
+++ b/src/renderer/utils/teamProvisioningPresentation.ts
@@ -32,6 +32,11 @@ interface FailedSpawnDetail {
reason: string | null;
}
+interface SkippedSpawnDetail {
+ name: string;
+ reason: string | null;
+}
+
type PendingDiagnosticBucket =
| 'shellOnly'
| 'runtimeProcess'
@@ -55,6 +60,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
}
+function isSkippedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean {
+ return entry?.launchState === 'skipped_for_launch' || entry?.skippedForLaunch === true;
+}
+
function shouldPreferSnapshotEntryOverLive(params: {
liveEntry: MemberSpawnStatusEntry | undefined;
snapshotEntry: MemberSpawnStatusEntry | undefined;
@@ -181,7 +190,12 @@ function getPendingDiagnosticNameGroups(params: {
snapshotEntry,
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
- if (!entry || entry.launchState === 'confirmed_alive' || isFailedSpawnEntry(entry)) {
+ if (
+ !entry ||
+ entry.launchState === 'confirmed_alive' ||
+ isFailedSpawnEntry(entry) ||
+ isSkippedSpawnEntry(entry)
+ ) {
continue;
}
if (
@@ -326,6 +340,56 @@ function getFailedSpawnDetails(params: {
.sort((left, right) => left.name.localeCompare(right.name));
}
+function getSkippedSpawnDetails(params: {
+ memberSpawnStatuses: MemberSpawnStatusCollection;
+ memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
+ memberSpawnSnapshotUpdatedAt?: string;
+}): SkippedSpawnDetail[] {
+ const names = new Set();
+ if (params.memberSpawnStatuses instanceof Map) {
+ for (const name of params.memberSpawnStatuses.keys()) {
+ names.add(name);
+ }
+ } else if (params.memberSpawnStatuses) {
+ for (const name of Object.keys(params.memberSpawnStatuses)) {
+ names.add(name);
+ }
+ }
+ for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) {
+ names.add(name);
+ }
+
+ if (names.size === 0) {
+ return [];
+ }
+
+ return [...names]
+ .map((name) => {
+ const liveEntry =
+ params.memberSpawnStatuses instanceof Map
+ ? params.memberSpawnStatuses.get(name)
+ : params.memberSpawnStatuses?.[name];
+ const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
+ return [
+ name,
+ getPreferredSpawnEntry({
+ liveEntry,
+ snapshotEntry,
+ snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
+ }),
+ ] as const;
+ })
+ .filter(([, entry]) => isSkippedSpawnEntry(entry))
+ .map(([name, entry]) => ({
+ name,
+ reason:
+ typeof entry?.skipReason === 'string' && entry.skipReason.trim().length > 0
+ ? entry.skipReason.trim()
+ : null,
+ }))
+ .sort((left, right) => left.name.localeCompare(right.name));
+}
+
function truncateFailureReason(reason: string, maxLength = 160): string {
const normalized = reason.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLength) {
@@ -381,6 +445,42 @@ function buildGenericFailedSpawnPanelMessage(
return `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`;
}
+function buildSkippedSpawnPanelMessage(
+ skippedSpawnDetails: readonly SkippedSpawnDetail[]
+): string | null {
+ if (skippedSpawnDetails.length === 0) {
+ return null;
+ }
+ if (skippedSpawnDetails.length === 1) {
+ const [skipped] = skippedSpawnDetails;
+ return skipped.reason
+ ? `${skipped.name} skipped for this launch - ${truncateFailureReason(skipped.reason, 220)}`
+ : `${skipped.name} skipped for this launch`;
+ }
+ const listedSkipped = skippedSpawnDetails
+ .slice(0, 3)
+ .map((skipped) =>
+ skipped.reason
+ ? `${skipped.name} - ${truncateFailureReason(skipped.reason, 100)}`
+ : skipped.name
+ )
+ .join('; ');
+ const remainingCount = skippedSpawnDetails.length - Math.min(skippedSpawnDetails.length, 3);
+ return `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`;
+}
+
+function buildSkippedSpawnCompactDetail(
+ skippedSpawnDetails: readonly SkippedSpawnDetail[]
+): string | null {
+ if (skippedSpawnDetails.length === 0) {
+ return null;
+ }
+ if (skippedSpawnDetails.length === 1) {
+ return `${skippedSpawnDetails[0].name} skipped`;
+ }
+ return `${skippedSpawnDetails.length} teammates skipped`;
+}
+
export interface TeamProvisioningPresentation {
progress: TeamProvisioningProgress;
isActive: boolean;
@@ -393,6 +493,7 @@ export interface TeamProvisioningPresentation {
processOnlyAliveCount: number;
pendingSpawnCount: number;
failedSpawnCount: number;
+ skippedSpawnCount: number;
allTeammatesConfirmedAlive: boolean;
hasMembersStillJoining: boolean;
remainingJoinCount: number;
@@ -454,6 +555,7 @@ export function buildTeamProvisioningPresentation({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
} = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses,
@@ -470,6 +572,13 @@ export function buildTeamProvisioningPresentation({
failedSpawnCount,
expectedTeammateCount
);
+ const skippedSpawnDetails = getSkippedSpawnDetails({
+ memberSpawnStatuses,
+ memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
+ memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
+ });
+ const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails);
+ const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails);
const permissionBlockedCount = countPermissionBlockedMembers({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
@@ -483,6 +592,7 @@ export function buildTeamProvisioningPresentation({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
});
const progressStepIndex = getDisplayStepIndex({
@@ -492,6 +602,7 @@ export function buildTeamProvisioningPresentation({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
});
if (isFailed) {
@@ -507,6 +618,7 @@ export function buildTeamProvisioningPresentation({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
allTeammatesConfirmedAlive,
hasMembersStillJoining,
remainingJoinCount,
@@ -542,31 +654,43 @@ export function buildTeamProvisioningPresentation({
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
- : hasMembersStillJoining
- ? pendingDetailPhrase
- : expectedTeammateCount === 0
- ? 'Lead online'
- : `All ${expectedTeammateCount} teammates joined`;
+ : skippedSpawnCount > 0
+ ? (skippedSpawnCompactDetail ??
+ `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
+ : hasMembersStillJoining
+ ? pendingDetailPhrase
+ : expectedTeammateCount === 0
+ ? 'Lead online'
+ : `All ${expectedTeammateCount} teammates joined`;
const readyDetailMessage =
failedSpawnCount > 0
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
- : expectedTeammateCount === 0
- ? 'Team provisioned - lead online'
- : allTeammatesConfirmedAlive
- ? `Team provisioned - all ${expectedTeammateCount} teammates joined`
- : hasMembersStillJoining
- ? pendingDetailPhrase
- : 'Team provisioned - teammates are still joining';
+ : skippedSpawnCount > 0
+ ? (skippedSpawnPanelMessage ??
+ `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
+ : expectedTeammateCount === 0
+ ? 'Team provisioned - lead online'
+ : allTeammatesConfirmedAlive
+ ? `Team provisioned - all ${expectedTeammateCount} teammates joined`
+ : hasMembersStillJoining
+ ? pendingDetailPhrase
+ : 'Team provisioned - teammates are still joining';
const readyDetailSeverity =
- failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined;
+ failedSpawnCount > 0 || skippedSpawnCount > 0
+ ? 'warning'
+ : hasMembersStillJoining
+ ? 'info'
+ : undefined;
const readyMessage =
failedSpawnCount > 0
? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`
- : expectedTeammateCount === 0
- ? 'Team launched - lead online'
- : allTeammatesConfirmedAlive
- ? `Team launched - all ${expectedTeammateCount} teammates joined`
- : 'Finishing launch';
+ : skippedSpawnCount > 0
+ ? `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped`
+ : expectedTeammateCount === 0
+ ? 'Team launched - lead online'
+ : allTeammatesConfirmedAlive
+ ? `Team launched - all ${expectedTeammateCount} teammates joined`
+ : 'Finishing launch';
return {
progress,
@@ -579,27 +703,45 @@ export function buildTeamProvisioningPresentation({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
allTeammatesConfirmedAlive,
hasMembersStillJoining,
remainingJoinCount,
panelTitle: 'Launch details',
- panelMessage: failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null,
+ panelMessage:
+ failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining
+ ? readyDetailMessage
+ : null,
panelMessageSeverity: readyDetailSeverity,
successMessage: readyMessage,
successMessageSeverity:
- failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success',
+ failedSpawnCount > 0 || skippedSpawnCount > 0
+ ? 'warning'
+ : hasMembersStillJoining
+ ? 'info'
+ : 'success',
defaultLiveOutputOpen: false,
compactTitle:
failedSpawnCount > 0
? 'Launch finished with errors'
- : hasMembersStillJoining
- ? 'Finishing launch'
- : 'Team launched',
+ : skippedSpawnCount > 0
+ ? 'Launch continued with skipped teammates'
+ : hasMembersStillJoining
+ ? 'Finishing launch'
+ : 'Team launched',
compactDetail: readyCompactDetail,
compactTone:
- failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success',
+ failedSpawnCount > 0 || skippedSpawnCount > 0
+ ? 'warning'
+ : hasMembersStillJoining
+ ? 'default'
+ : 'success',
currentStepIndex:
- failedSpawnCount > 0 ? 2 : hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX,
+ failedSpawnCount > 0 || skippedSpawnCount > 0
+ ? 2
+ : hasMembersStillJoining
+ ? 2
+ : DISPLAY_COMPLETE_STEP_INDEX,
};
}
@@ -633,6 +775,7 @@ export function buildTeamProvisioningPresentation({
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
+ skippedSpawnCount,
allTeammatesConfirmedAlive,
hasMembersStillJoining,
remainingJoinCount,
@@ -640,26 +783,33 @@ export function buildTeamProvisioningPresentation({
panelMessage:
failedSpawnCount > 0
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
- : hasMembersStillJoining &&
- permissionBlockedCount > 0 &&
- permissionBlockedCount === remainingJoinCount
- ? activePendingDetailPhrase
- : progress.message,
- panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
+ : skippedSpawnCount > 0
+ ? (skippedSpawnPanelMessage ??
+ `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
+ : hasMembersStillJoining &&
+ permissionBlockedCount > 0 &&
+ permissionBlockedCount === remainingJoinCount
+ ? activePendingDetailPhrase
+ : progress.message,
+ panelMessageSeverity:
+ failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
defaultLiveOutputOpen: false,
compactTitle: 'Launching team',
compactDetail:
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
`${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
- : hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
- ? permissionBlockedCount === remainingJoinCount
- ? buildAwaitingPermissionPhrase(permissionBlockedCount)
- : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
- : expectedTeammateCount > 0 && progressStepIndex >= 2
- ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
- : progress.message,
- compactTone: failedSpawnCount > 0 ? 'warning' : 'default',
+ : skippedSpawnCount > 0
+ ? (skippedSpawnCompactDetail ??
+ `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
+ : hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
+ ? permissionBlockedCount === remainingJoinCount
+ ? buildAwaitingPermissionPhrase(permissionBlockedCount)
+ : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
+ : expectedTeammateCount > 0 && progressStepIndex >= 2
+ ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
+ : progress.message,
+ compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default',
};
}
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index ff84e0c1..9cbc679e 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -542,6 +542,7 @@ export interface TeamsAPI {
getMemberSpawnStatuses: (teamName: string) => Promise;
getTeamAgentRuntime: (teamName: string) => Promise;
restartMember: (teamName: string, memberName: string) => Promise;
+ skipMemberForLaunch: (teamName: string, memberName: string) => Promise;
softDeleteTask: (teamName: string, taskId: string) => Promise;
restoreTask: (teamName: string, taskId: string) => Promise;
getDeletedTasks: (teamName: string) => Promise;
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 66b4cde3..d5a7fbfa 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -73,6 +73,8 @@ export interface TeamSummary {
confirmedMemberCount?: number;
/** Missing teammate names from the last partial launch marker. */
missingMembers?: string[];
+ /** Teammates intentionally skipped for the last launch. */
+ skippedMembers?: string[];
/** Durable aggregate launch state derived from persisted launch-state evidence. */
teamLaunchState?: TeamLaunchAggregateState;
/** ISO timestamp of the last durable launch-state evaluation. */
@@ -81,6 +83,7 @@ export interface TeamSummary {
confirmedCount?: number;
pendingCount?: number;
failedCount?: number;
+ skippedCount?: number;
runtimeAlivePendingCount?: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
@@ -683,14 +686,19 @@ export interface AddTaskCommentRequest {
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
-export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
+export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped';
export type MemberLaunchState =
| 'starting'
| 'runtime_pending_bootstrap'
| 'runtime_pending_permission'
| 'confirmed_alive'
- | 'failed_to_start';
-export type TeamLaunchAggregateState = 'clean_success' | 'partial_pending' | 'partial_failure';
+ | 'failed_to_start'
+ | 'skipped_for_launch';
+export type TeamLaunchAggregateState =
+ | 'clean_success'
+ | 'partial_pending'
+ | 'partial_failure'
+ | 'partial_skipped';
export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled';
export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
@@ -939,6 +947,9 @@ export interface PersistedTeamLaunchMemberState {
laneOwnerProviderId?: TeamProviderId;
launchIdentity?: ProviderModelLaunchIdentity;
launchState: MemberLaunchState;
+ skippedForLaunch?: boolean;
+ skipReason?: string;
+ skippedAt?: string;
agentToolAccepted: boolean;
runtimeAlive: boolean;
bootstrapConfirmed: boolean;
@@ -964,6 +975,7 @@ export interface PersistedTeamLaunchSummary {
confirmedCount: number;
pendingCount: number;
failedCount: number;
+ skippedCount?: number;
runtimeAlivePendingCount: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
@@ -1092,6 +1104,10 @@ export interface MemberSpawnStatusEntry {
error?: string;
/** Hard failure reason for failed_to_start. */
hardFailureReason?: string;
+ /** True when the user intentionally skipped this teammate for the current launch only. */
+ skippedForLaunch?: boolean;
+ skipReason?: string;
+ skippedAt?: string;
/**
* Optional provenance for `online`.
* - heartbeat: teammate sent a real inbox/native message after bootstrap
diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts
index 4f2ed047..08cbbc9f 100644
--- a/test/main/services/infrastructure/FileWatcher.test.ts
+++ b/test/main/services/infrastructure/FileWatcher.test.ts
@@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
import type * as FsType from 'fs';
import * as os from 'os';
import * as path from 'path';
+import { Readable } from 'stream';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@shared/utils/logger', () => ({
@@ -500,6 +501,69 @@ describe('FileWatcher', () => {
watcher.stop();
});
+
+ it('retires catch-up files after repeated stat timeouts', async () => {
+ vi.useRealTimers();
+ vi.mocked(errorDetector.detectErrors).mockClear();
+
+ const fsProvider = {
+ type: 'local' as const,
+ exists: vi.fn().mockResolvedValue(true),
+ readFile: vi.fn().mockResolvedValue(''),
+ stat: vi.fn().mockRejectedValue(new Error('stat timeout')),
+ readdir: vi.fn().mockResolvedValue([]),
+ createReadStream: vi.fn(() => Readable.from([])),
+ dispose: vi.fn(),
+ };
+
+ const dataCache = new DataCache(50, 10, false);
+ const notificationManager = createMockNotificationManager();
+ const watcher = new FileWatcher(
+ dataCache,
+ '/watch-root/projects',
+ '/watch-root/todos',
+ fsProvider
+ );
+ watcher.setNotificationManager(notificationManager);
+
+ const filePath = '/watch-root/projects/test-project/session-timeout.jsonl';
+ const watcherAny = watcher as unknown as {
+ isWatching: boolean;
+ activeSessionFiles: Map<
+ string,
+ { projectId: string; sessionId: string; lastObservedAt: number }
+ >;
+ catchUpStatFailures: Map;
+ lastProcessedSize: Map;
+ lastProcessedLineCount: Map;
+ runCatchUpScan: () => Promise;
+ };
+ watcherAny.isWatching = true;
+ watcherAny.activeSessionFiles.set(filePath, {
+ projectId: 'test-project',
+ sessionId: 'session-timeout',
+ lastObservedAt: Date.now(),
+ });
+ watcherAny.lastProcessedSize.set(filePath, 100);
+ watcherAny.lastProcessedLineCount.set(filePath, 5);
+
+ await watcherAny.runCatchUpScan();
+ expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true);
+ expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(1);
+
+ await watcherAny.runCatchUpScan();
+ expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true);
+ expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(2);
+
+ await watcherAny.runCatchUpScan();
+ expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false);
+ expect(watcherAny.catchUpStatFailures.has(filePath)).toBe(false);
+ expect(watcherAny.lastProcessedSize.get(filePath)).toBe(100);
+ expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(5);
+ expect(errorDetector.detectErrors).not.toHaveBeenCalled();
+
+ watcher.stop();
+ });
});
// ===========================================================================
diff --git a/test/main/services/team/OpenCodeLaunchModeEnv.test.ts b/test/main/services/team/OpenCodeLaunchModeEnv.test.ts
deleted file mode 100644
index 14b3b4a4..00000000
--- a/test/main/services/team/OpenCodeLaunchModeEnv.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { resolveOpenCodeTeamLaunchModeFromEnv } from '../../../../src/main/services/team/opencode/config/OpenCodeLaunchModeEnv';
-
-describe('resolveOpenCodeTeamLaunchModeFromEnv', () => {
- it('defaults to production so OpenCode is visible while strict readiness remains authoritative', () => {
- expect(resolveOpenCodeTeamLaunchModeFromEnv({})).toBe('production');
- });
-
- it('preserves explicit launch mode overrides', () => {
- expect(
- resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'disabled' })
- ).toBe('disabled');
- expect(
- resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'dogfood' })
- ).toBe('dogfood');
- expect(
- resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'production' })
- ).toBe('production');
- });
-
- it('keeps the legacy dogfood flag as an explicit opt-in', () => {
- expect(resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_DOGFOOD: '1' })).toBe(
- 'dogfood'
- );
- });
-
- it('falls back to production for invalid launch mode values', () => {
- expect(
- resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'enabled' })
- ).toBe('production');
- });
-});
diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts
index 6328ddc0..f46a227b 100644
--- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts
+++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts
@@ -15,9 +15,7 @@ import {
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
-import {
- getTeamBootstrapStatePath,
-} from '../../../../src/main/services/team/TeamBootstrapStateReader';
+import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader';
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
@@ -68,112 +66,106 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
- it(
- 'recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned',
- async () => {
- const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
- const orchestratorCli =
- process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
- await assertExecutable(orchestratorCli);
+ it('recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', async () => {
+ const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
+ const orchestratorCli =
+ process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
+ await assertExecutable(orchestratorCli);
- const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
- const bridgeEnv = {
- ...createStableBridgeEnv(),
- PATH: withBunOnPath(process.env.PATH ?? ''),
- XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'),
- CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
- CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
- };
- const bridgeClient = new OpenCodeBridgeCommandClient({
- binaryPath: orchestratorCli,
- tempDirectory: path.join(tempDir, 'bridge-input'),
- env: bridgeEnv,
- });
- const stateChangingCommands = createStateChangingCommands({
- bridge: bridgeClient,
- controlDir: path.join(tempDir, 'control'),
- });
- const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
- stateChangingCommands,
- timeoutMs: 180_000,
- launchTimeoutMs: 180_000,
- reconcileTimeoutMs: 90_000,
- stopTimeoutMs: 90_000,
- });
- const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
- launchMode: 'dogfood',
- });
- const svc = new TeamProvisioningService();
- svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
+ const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
+ const bridgeEnv = {
+ ...createStableBridgeEnv(),
+ PATH: withBunOnPath(process.env.PATH ?? ''),
+ XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'),
+ CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
+ CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
+ };
+ const bridgeClient = new OpenCodeBridgeCommandClient({
+ binaryPath: orchestratorCli,
+ tempDirectory: path.join(tempDir, 'bridge-input'),
+ env: bridgeEnv,
+ });
+ const stateChangingCommands = createStateChangingCommands({
+ bridge: bridgeClient,
+ controlDir: path.join(tempDir, 'control'),
+ });
+ const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
+ stateChangingCommands,
+ timeoutMs: 180_000,
+ launchTimeoutMs: 180_000,
+ reconcileTimeoutMs: 90_000,
+ stopTimeoutMs: 90_000,
+ });
+ const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
+ const svc = new TeamProvisioningService();
+ svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
- const teamName = `mixed-opencode-recovery-${Date.now()}`;
- const launchedLanes: TeamRuntimeLaunchInput[] = [];
+ const teamName = `mixed-opencode-recovery-${Date.now()}`;
+ const launchedLanes: TeamRuntimeLaunchInput[] = [];
- await writeMixedRecoveryFixtures({
+ await writeMixedRecoveryFixtures({
+ teamName,
+ projectPath: PROJECT_PATH,
+ secondaryMembers: ['bob'],
+ });
+
+ try {
+ const launchInput = createSecondaryLaneLaunchInput({
teamName,
- projectPath: PROJECT_PATH,
- secondaryMembers: ['bob'],
+ laneId: 'secondary:opencode:bob',
+ memberName: 'bob',
+ selectedModel,
+ });
+ launchedLanes.push(launchInput);
+ const launchResult = await adapter.launch(launchInput);
+ expect(launchResult.teamLaunchState).toBe('clean_success');
+ expect(launchResult.members.bob).toMatchObject({
+ launchState: 'confirmed_alive',
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
});
- try {
- const launchInput = createSecondaryLaneLaunchInput({
- teamName,
- laneId: 'secondary:opencode:bob',
- memberName: 'bob',
- selectedModel,
- });
- launchedLanes.push(launchInput);
- const launchResult = await adapter.launch(launchInput);
- expect(launchResult.teamLaunchState).toBe('clean_success');
- expect(launchResult.members.bob).toMatchObject({
- launchState: 'confirmed_alive',
- runtimeAlive: true,
- bootstrapConfirmed: true,
- });
+ await upsertOpenCodeRuntimeLaneIndexEntry({
+ teamsBasePath: getTeamsBasePath(),
+ teamName,
+ laneId: launchInput.laneId ?? 'secondary:opencode:bob',
+ state: 'active',
+ });
- await upsertOpenCodeRuntimeLaneIndexEntry({
- teamsBasePath: getTeamsBasePath(),
- teamName,
- laneId: launchInput.laneId ?? 'secondary:opencode:bob',
- state: 'active',
- });
+ const result = await svc.getMemberSpawnStatuses(teamName);
- const result = await svc.getMemberSpawnStatuses(teamName);
-
- expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob']));
- expect(result.statuses.bob).toMatchObject({
- status: 'online',
- launchState: 'confirmed_alive',
- });
- expect(result.statuses.bob.error).toBeUndefined();
- await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
- {
- lanes: {
- [launchInput.laneId ?? 'secondary:opencode:bob']: {
- state: 'active',
- },
- },
- }
- );
- } finally {
- for (const launchInput of launchedLanes) {
- await adapter
- .stop({
- runId: launchInput.runId,
- laneId: launchInput.laneId,
- teamName,
- cwd: PROJECT_PATH,
- providerId: 'opencode',
- reason: 'cleanup',
- previousLaunchState: null,
- force: true,
- } satisfies TeamRuntimeStopInput)
- .catch(() => undefined);
- }
+ expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob']));
+ expect(result.statuses.bob).toMatchObject({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ });
+ expect(result.statuses.bob.error).toBeUndefined();
+ await expect(
+ readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
+ ).resolves.toMatchObject({
+ lanes: {
+ [launchInput.laneId ?? 'secondary:opencode:bob']: {
+ state: 'active',
+ },
+ },
+ });
+ } finally {
+ for (const launchInput of launchedLanes) {
+ await adapter
+ .stop({
+ runId: launchInput.runId,
+ laneId: launchInput.laneId,
+ teamName,
+ cwd: PROJECT_PATH,
+ providerId: 'opencode',
+ reason: 'cleanup',
+ previousLaunchState: null,
+ force: true,
+ } satisfies TeamRuntimeStopInput)
+ .catch(() => undefined);
}
- },
- 240_000
- );
+ }
+ }, 240_000);
liveMultiLaneIt(
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
@@ -207,9 +199,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
reconcileTimeoutMs: 90_000,
stopTimeoutMs: 90_000,
});
- const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
- launchMode: 'dogfood',
- });
+ const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
@@ -259,16 +249,16 @@ liveDescribe('OpenCode mixed recovery live e2e', () => {
});
expect(result.statuses[memberName]?.error).toBeUndefined();
}
- await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
- {
- lanes: Object.fromEntries(
- sideMembers.map((memberName) => [
- `secondary:opencode:${memberName}`,
- { state: 'active' },
- ])
- ),
- }
- );
+ await expect(
+ readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
+ ).resolves.toMatchObject({
+ lanes: Object.fromEntries(
+ sideMembers.map((memberName) => [
+ `secondary:opencode:${memberName}`,
+ { state: 'active' },
+ ])
+ ),
+ });
} finally {
for (const launchInput of launchedLanes) {
await adapter
@@ -360,10 +350,7 @@ async function writeMixedRecoveryFixtures(input: {
name: input.teamName,
projectPath: input.projectPath,
leadSessionId: 'lead-session',
- members: [
- { name: 'team-lead', agentType: 'team-lead' },
- { name: 'alice' },
- ],
+ members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'alice' }],
},
null,
2
diff --git a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts
deleted file mode 100644
index d8c6159c..00000000
--- a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-import { promises as fs } from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-
-import {
- assertOpenCodeProductionE2EArtifactGate,
- buildOpenCodeProjectPathFingerprint,
- OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
- validateOpenCodeProductionE2EEvidence,
- type OpenCodeProductionE2EEvidence,
-} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
-import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
-import {
- buildOpenCodeCanonicalMcpToolId,
- REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
-} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
-
-describe('OpenCodeProductionE2EEvidence', () => {
- let tempDir: string;
- const now = new Date('2026-04-21T12:00:00.000Z');
-
- beforeEach(async () => {
- tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-e2e-'));
- });
-
- afterEach(async () => {
- await fs.rm(tempDir, { recursive: true, force: true });
- });
-
- it('accepts production evidence when runtime identity, project context and required MCP tools match', () => {
- const evidence = passingEvidence();
-
- expect(validateOpenCodeProductionE2EEvidence(evidence)).toEqual(evidence);
- expect(
- assertOpenCodeProductionE2EArtifactGate({
- evidence,
- artifactPath: '/tmp/opencode-e2e',
- now,
- expected: {
- opencodeVersion: '1.14.19',
- binaryFingerprint: 'version:1.14.19',
- capabilitySnapshotId: 'cap-1',
- selectedModel: 'openai/gpt-5.4-mini',
- projectPathFingerprint: 'project-a',
- requiredMcpTools: ['agent-teams_runtime_deliver_message'],
- },
- })
- ).toEqual({
- ok: true,
- diagnostics: [],
- });
- });
-
- it('fails closed for stale, mismatched or incomplete evidence', () => {
- const expired = passingEvidence({
- expiresAt: '2026-04-21T11:59:59.000Z',
- selectedModel: 'openrouter/anthropic/claude-sonnet-4.5',
- requiredSignals: requiredSignals({ stale_run_rejected: false }),
- mcpTools: {
- requiredTools: ['agent-teams_runtime_deliver_message'],
- observedTools: [],
- },
- });
-
- expect(
- assertOpenCodeProductionE2EArtifactGate({
- evidence: expired,
- artifactPath: '/tmp/opencode-e2e',
- now,
- expected: {
- opencodeVersion: '1.14.19',
- binaryFingerprint: 'version:1.14.19',
- capabilitySnapshotId: 'cap-1',
- selectedModel: 'openai/gpt-5.4-mini',
- projectPathFingerprint: 'project-a',
- requiredMcpTools: ['agent-teams_runtime_deliver_message'],
- },
- })
- ).toMatchObject({
- ok: false,
- diagnostics: expect.arrayContaining([
- 'OpenCode production E2E evidence is expired',
- 'OpenCode production E2E evidence is missing signals: stale_run_rejected',
- 'OpenCode production E2E evidence is missing observed MCP tools: agent-teams_runtime_deliver_message',
- ]),
- });
- });
-
- it('reads missing evidence as a production-blocking diagnostic and quarantines corrupt artifacts', async () => {
- const filePath = path.join(tempDir, 'production-e2e-evidence.json');
- const store = new OpenCodeProductionE2EEvidenceStore({
- filePath,
- clock: () => now,
- });
-
- await expect(store.read()).resolves.toMatchObject({
- ok: true,
- evidence: null,
- artifactPath: filePath,
- diagnostics: ['OpenCode production E2E evidence artifact has not been written yet'],
- });
-
- await fs.mkdir(path.dirname(filePath), { recursive: true });
- await fs.writeFile(filePath, '{broken', 'utf8');
- const corrupt = await store.read();
- expect(corrupt).toMatchObject({
- ok: false,
- evidence: null,
- artifactPath: filePath,
- });
- expect(corrupt.diagnostics[0]).toContain(
- 'OpenCode production E2E evidence store is unreadable'
- );
- });
-
- it('writes evidence with the store path as artifactPath when the input omits it', async () => {
- const filePath = path.join(tempDir, 'production-e2e-evidence.json');
- const store = new OpenCodeProductionE2EEvidenceStore({
- filePath,
- clock: () => now,
- });
-
- await store.write({
- ...passingEvidence(),
- artifactPath: null,
- });
-
- await expect(store.read()).resolves.toMatchObject({
- ok: true,
- evidence: {
- artifactPath: filePath,
- evidenceId: 'e2e-1',
- },
- diagnostics: [],
- });
- });
-
- it('stores production evidence for multiple raw model ids and reads exact model matches when no project context is provided', async () => {
- const filePath = path.join(tempDir, 'production-e2e-evidence.json');
- const store = new OpenCodeProductionE2EEvidenceStore({
- filePath,
- clock: () => now,
- });
-
- await store.write(passingEvidence({ selectedModel: 'opencode/big-pickle' }));
- await store.write(
- passingEvidence({
- evidenceId: 'e2e-2',
- selectedModel: 'opencode/minimax-m2.5-free',
- })
- );
-
- await expect(
- store.read({ selectedModel: 'opencode/minimax-m2.5-free' })
- ).resolves.toMatchObject({
- ok: true,
- evidence: {
- evidenceId: 'e2e-2',
- selectedModel: 'opencode/minimax-m2.5-free',
- },
- diagnostics: [],
- });
-
- await expect(store.read({ selectedModel: 'openai/gpt-5.4-mini' })).resolves.toMatchObject({
- ok: true,
- evidence: null,
- diagnostics: [
- 'OpenCode production E2E evidence artifact has no entry for selected model openai/gpt-5.4-mini',
- ],
- });
- });
-
- it('reuses the current project production proof even when the requested OpenCode model differs', async () => {
- const filePath = path.join(tempDir, 'production-e2e-evidence.json');
- const store = new OpenCodeProductionE2EEvidenceStore({
- filePath,
- clock: () => now,
- });
-
- await store.write(
- passingEvidence({
- evidenceId: 'e2e-project-a',
- selectedModel: 'opencode/minimax-m2.5-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
- })
- );
- await store.write(
- passingEvidence({
- evidenceId: 'e2e-project-b',
- selectedModel: 'opencode/minimax-m2.5-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
- })
- );
-
- await expect(
- store.read({
- selectedModel: 'opencode/nemotron-3-super-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
- })
- ).resolves.toMatchObject({
- ok: true,
- evidence: {
- evidenceId: 'e2e-project-b',
- selectedModel: 'opencode/minimax-m2.5-free',
- },
- diagnostics: [],
- });
- });
-
- it('prefers a runtime-compatible project proof over a newer stale one from the same cwd', async () => {
- const filePath = path.join(tempDir, 'production-e2e-evidence.json');
- const store = new OpenCodeProductionE2EEvidenceStore({
- filePath,
- clock: () => now,
- });
-
- await store.write(
- passingEvidence({
- evidenceId: 'stale-newer',
- createdAt: '2026-04-21T12:05:00.000Z',
- selectedModel: 'opencode/big-pickle',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
- capabilitySnapshotId: 'cap-stale',
- })
- );
- await store.write(
- passingEvidence({
- evidenceId: 'matching-older',
- createdAt: '2026-04-21T12:00:00.000Z',
- selectedModel: 'opencode/minimax-m2.5-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
- capabilitySnapshotId: 'cap-current',
- })
- );
-
- await expect(
- store.read({
- selectedModel: 'opencode/nemotron-3-super-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
- opencodeVersion: '1.14.19',
- binaryFingerprint: 'version:1.14.19',
- capabilitySnapshotId: 'cap-current',
- })
- ).resolves.toMatchObject({
- ok: true,
- evidence: {
- evidenceId: 'matching-older',
- selectedModel: 'opencode/minimax-m2.5-free',
- },
- diagnostics: [],
- });
- });
-
- it('stores production evidence for the same raw model across multiple project contexts', async () => {
- const filePath = path.join(tempDir, 'production-e2e-evidence.json');
- const store = new OpenCodeProductionE2EEvidenceStore({
- filePath,
- clock: () => now,
- });
-
- await store.write(
- passingEvidence({
- evidenceId: 'e2e-project-a',
- selectedModel: 'opencode/minimax-m2.5-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'),
- })
- );
- await store.write(
- passingEvidence({
- evidenceId: 'e2e-project-b',
- selectedModel: 'opencode/minimax-m2.5-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
- })
- );
-
- await expect(
- store.read({
- selectedModel: 'opencode/minimax-m2.5-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'),
- })
- ).resolves.toMatchObject({
- ok: true,
- evidence: {
- evidenceId: 'e2e-project-b',
- selectedModel: 'opencode/minimax-m2.5-free',
- },
- diagnostics: [],
- });
-
- await expect(
- store.read({
- selectedModel: 'opencode/minimax-m2.5-free',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-c'),
- })
- ).resolves.toMatchObject({
- ok: true,
- evidence: null,
- diagnostics: ['OpenCode production E2E evidence artifact has no entry for the current working directory'],
- });
- });
-});
-
-function passingEvidence(
- overrides: Partial = {}
-): OpenCodeProductionE2EEvidence {
- const createdAt = '2026-04-21T12:00:00.000Z';
- const sessionId = 'session-1';
- const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
- buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
- );
-
- return {
- schemaVersion: 1,
- evidenceId: 'e2e-1',
- createdAt,
- expiresAt: '2026-04-21T12:10:00.000Z',
- version: '1.14.19',
- passed: true,
- artifactPath: '/tmp/opencode-e2e',
- binaryFingerprint: 'version:1.14.19',
- capabilitySnapshotId: 'cap-1',
- selectedModel: 'openai/gpt-5.4-mini',
- projectPathFingerprint: 'project-a',
- requiredSignals: requiredSignals(),
- mcpTools: {
- requiredTools: requiredToolIds,
- observedTools: requiredToolIds,
- },
- launch: {
- runId: 'run-1',
- teamId: 'team-a',
- teamLaunchState: 'ready',
- memberCount: 1,
- sessions: [
- {
- memberName: 'Dev',
- sessionId,
- launchState: 'confirmed_alive',
- },
- ],
- durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
- name,
- observedAt: createdAt,
- })),
- },
- reconcile: {
- runId: 'run-1',
- teamLaunchState: 'ready',
- memberCount: 1,
- },
- stop: {
- runId: 'run-1',
- stopped: true,
- stoppedSessionIds: [sessionId],
- },
- logProjection: {
- observed: true,
- projectedMessageCount: 1,
- },
- ...overrides,
- };
-}
-
-function requiredSignals(
- overrides: Partial<
- Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean>
- > = {}
-) {
- return Object.fromEntries(
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true])
- ) as OpenCodeProductionE2EEvidence['requiredSignals'];
-}
diff --git a/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts
deleted file mode 100644
index 999335b9..00000000
--- a/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import * as path from 'path';
-
-import { describe, expect, it } from 'vitest';
-
-import {
- OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE,
- OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV,
- resolveOpenCodeProductionE2EEvidencePath,
-} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath';
-
-describe('OpenCodeProductionE2EEvidencePath', () => {
- it('defaults to the app-owned bridge control directory', () => {
- expect(
- resolveOpenCodeProductionE2EEvidencePath({
- bridgeControlDir: '/app/user-data/opencode-bridge',
- env: {},
- })
- ).toBe(path.join('/app/user-data/opencode-bridge', OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE));
- });
-
- it('allows release and local proof runs to point production at an explicit artifact', () => {
- const relativeOverride = 'tmp/opencode-production-evidence.json';
-
- expect(
- resolveOpenCodeProductionE2EEvidencePath({
- bridgeControlDir: '/app/user-data/opencode-bridge',
- env: {
- [OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]: ` ${relativeOverride} `,
- },
- })
- ).toBe(path.resolve(relativeOverride));
- });
-});
diff --git a/test/main/services/team/OpenCodeProductionGate.live.test.ts b/test/main/services/team/OpenCodeProductionGate.live.test.ts
deleted file mode 100644
index 5a500817..00000000
--- a/test/main/services/team/OpenCodeProductionGate.live.test.ts
+++ /dev/null
@@ -1,468 +0,0 @@
-import { constants as fsConstants, promises as fs } from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-
-import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
-import {
- createOpenCodeBridgeCommandLeaseStore,
- createOpenCodeBridgeCommandLedgerStore,
-} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
-import {
- createOpenCodeBridgeClientIdentity,
- OpenCodeBridgeCommandHandshakePort,
-} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
-import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
-import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
-import {
- assertOpenCodeProductionE2EArtifactGate,
- buildOpenCodeProjectPathFingerprint,
- OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
- OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS,
- OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
- type OpenCodeProductionE2EEvidence,
-} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
-import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
-import {
- buildOpenCodeCanonicalMcpToolId,
- REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
-} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
-import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
-import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy';
-
-import type {
- OpenCodeBridgeRuntimeSnapshot,
- OpenCodeLaunchTeamCommandData,
- OpenCodeStopTeamCommandData,
- RuntimeStoreManifestEvidence,
-} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
-import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
-import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
-
-const liveDescribe = process.env.OPENCODE_E2E === '1' ? describe : describe.skip;
-
-const DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH = path.join(
- os.userInfo().homedir,
- 'Library',
- 'Application Support',
- 'Agent Teams UI',
- 'opencode-bridge',
- 'production-e2e-evidence.json'
-);
-const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
-const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
-const DEFAULT_MODEL = 'opencode/big-pickle';
-
-liveDescribe('OpenCode production gate live e2e', () => {
- let tempDir: string;
-
- beforeEach(async () => {
- tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-gate-e2e-'));
- });
-
- afterEach(async () => {
- await fs.rm(tempDir, { recursive: true, force: true });
- });
-
- it('runs live launch/reconcile/transcript/stop and accepts production evidence with app MCP tool proof', async () => {
- const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
- const orchestratorCli =
- process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
- await assertExecutable(orchestratorCli);
-
- const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
- const bridgeEnv = {
- ...createStableBridgeEnv(),
- PATH: withBunOnPath(process.env.PATH ?? ''),
- CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
- CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
- };
- const bridgeClient = new OpenCodeBridgeCommandClient({
- binaryPath: orchestratorCli,
- tempDirectory: path.join(tempDir, 'bridge-input'),
- env: bridgeEnv,
- });
- const stateChangingCommands = createStateChangingCommands({
- bridge: bridgeClient,
- controlDir: path.join(tempDir, 'control'),
- });
- const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
- stateChangingCommands,
- timeoutMs: 180_000,
- launchTimeoutMs: 180_000,
- reconcileTimeoutMs: 90_000,
- stopTimeoutMs: 90_000,
- });
-
- const readiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({
- projectPath: PROJECT_PATH,
- selectedModel,
- requireExecutionProbe: false,
- });
- const initialRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
- if (!initialRuntime) {
- throw new Error(
- `OpenCode live readiness did not return runtime snapshot: ${[
- ...readiness.diagnostics,
- ...readiness.missing,
- ].join('; ')}`
- );
- }
- expect(initialRuntime?.version).toBe('1.14.19');
- expect(initialRuntime?.capabilitySnapshotId).toBeTruthy();
-
- const runId = `opencode-e2e-${Date.now()}`;
- const teamName = `opencode-e2e-team-${Date.now()}`;
- const memberName = 'E2E';
- let launch: OpenCodeLaunchTeamCommandData | null = null;
- let reconcile: OpenCodeLaunchTeamCommandData | null = null;
- let stop: OpenCodeStopTeamCommandData | null = null;
- let transcriptMessages = 0;
- let staleRunRejected = false;
-
- try {
- launch = await readinessBridge.launchOpenCodeTeam({
- mode: 'dogfood',
- runId,
- laneId: 'primary',
- teamId: teamName,
- teamName,
- projectPath: PROJECT_PATH,
- selectedModel,
- members: [
- {
- name: memberName,
- role: 'e2e',
- prompt: 'Reply with exactly: opencode-production-gate-e2e',
- },
- ],
- leadPrompt: 'Live OpenCode production gate e2e',
- expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
- manifestHighWatermark: null,
- });
-
- expect(launch.teamLaunchState).toBe('ready');
- expect(launch.members[memberName]?.launchState).toBe('confirmed_alive');
-
- reconcile = await readinessBridge.reconcileOpenCodeTeam({
- runId,
- laneId: 'primary',
- teamId: teamName,
- teamName,
- projectPath: PROJECT_PATH,
- expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
- manifestHighWatermark: null,
- expectedMembers: [{ name: memberName, model: selectedModel }],
- reason: 'production_gate_e2e',
- });
- expect(reconcile.teamLaunchState).toBe('ready');
-
- const transcript = await bridgeClient.execute<
- { teamId: string; teamName: string; laneId: string; memberName: string },
- { logProjection?: { messages?: unknown[] }; messages?: unknown[] }
- >(
- 'opencode.getRuntimeTranscript',
- { teamId: teamName, teamName, laneId: 'primary', memberName },
- { cwd: PROJECT_PATH, timeoutMs: 60_000 }
- );
- expect(transcript.ok).toBe(true);
- if (transcript.ok) {
- transcriptMessages =
- transcript.data.logProjection?.messages?.length ?? transcript.data.messages?.length ?? 0;
- expect(transcriptMessages).toBeGreaterThan(0);
- }
-
- staleRunRejected = await rejectsStaleCapability({
- stateChangingCommands,
- teamName,
- runId: `${runId}-stale`,
- selectedModel,
- });
-
- stop = await readinessBridge.stopOpenCodeTeam({
- runId,
- laneId: 'primary',
- teamId: teamName,
- teamName,
- projectPath: PROJECT_PATH,
- expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
- manifestHighWatermark: null,
- reason: 'production_gate_e2e_cleanup',
- force: true,
- });
- expect(stop.stopped).toBe(true);
-
- const finalReadiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({
- projectPath: PROJECT_PATH,
- selectedModel,
- requireExecutionProbe: true,
- });
- const finalRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH);
- if (!finalRuntime) {
- throw new Error(
- `OpenCode final readiness did not return runtime snapshot: ${[
- ...finalReadiness.diagnostics,
- ...finalReadiness.missing,
- ].join('; ')}`
- );
- }
- expect(finalRuntime.version).toBe('1.14.19');
- expect(finalRuntime.capabilitySnapshotId).toBeTruthy();
-
- const candidate = buildCandidateEvidence({
- runId,
- teamName,
- memberName,
- selectedModel,
- runtime: finalRuntime,
- readinessObservedTools: readiness.evidence.observedMcpTools,
- launch,
- reconcile,
- stop,
- transcriptMessages,
- staleRunRejected,
- appMcpToolsVisible: readiness.requiredToolsPresent,
- });
- const gate = assertOpenCodeProductionE2EArtifactGate({
- evidence: candidate,
- artifactPath: candidate.artifactPath,
- expected: {
- opencodeVersion: finalRuntime.version ?? null,
- binaryFingerprint: finalRuntime.binaryFingerprint ?? null,
- capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null,
- selectedModel,
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
- requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
- buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
- ),
- },
- });
-
- expect(gate).toEqual({
- ok: true,
- diagnostics: [],
- });
- await writeProductionEvidenceIfRequested(candidate);
- } finally {
- if (!stop) {
- await readinessBridge
- .stopOpenCodeTeam({
- runId,
- laneId: 'primary',
- teamId: teamName,
- teamName,
- projectPath: PROJECT_PATH,
- expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null,
- manifestHighWatermark: null,
- reason: 'production_gate_e2e_finally_cleanup',
- force: true,
- })
- .catch(() => undefined);
- }
- }
- }, 240_000);
-});
-
-async function writeProductionEvidenceIfRequested(
- evidence: OpenCodeProductionE2EEvidence
-): Promise {
- const explicitPath = process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim();
- const writeAppEvidence = process.env.OPENCODE_E2E_WRITE_APP_EVIDENCE === '1';
- const filePath =
- explicitPath || (writeAppEvidence ? DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH : '');
- if (!filePath) {
- return;
- }
-
- const store = new OpenCodeProductionE2EEvidenceStore({ filePath });
- await store.write({
- ...evidence,
- artifactPath: filePath,
- });
-}
-
-function createStateChangingCommands(input: {
- bridge: OpenCodeBridgeCommandExecutor;
- controlDir: string;
-}): OpenCodeStateChangingBridgeCommandService {
- const clientIdentity = createOpenCodeBridgeClientIdentity({
- appVersion: '1.3.0-e2e',
- gitSha: null,
- buildId: 'opencode-production-gate-e2e',
- });
-
- return new OpenCodeStateChangingBridgeCommandService({
- expectedClientIdentity: clientIdentity,
- handshakePort: new OpenCodeBridgeCommandHandshakePort({
- bridge: input.bridge,
- clientIdentity,
- }),
- leaseStore: createOpenCodeBridgeCommandLeaseStore({
- filePath: path.join(input.controlDir, 'leases.json'),
- }),
- ledger: createOpenCodeBridgeCommandLedgerStore({
- filePath: path.join(input.controlDir, 'ledger.json'),
- }),
- bridge: input.bridge,
- manifestReader: new StaticManifestReader(),
- });
-}
-
-class StaticManifestReader implements RuntimeStoreManifestReader {
- async read(): Promise {
- return {
- highWatermark: 0,
- activeRunId: null,
- capabilitySnapshotId: null,
- };
- }
-}
-
-async function rejectsStaleCapability(input: {
- stateChangingCommands: OpenCodeStateChangingBridgeCommandService;
- teamName: string;
- runId: string;
- selectedModel: string;
-}): Promise {
- try {
- await input.stateChangingCommands.execute({
- command: 'opencode.reconcileTeam',
- teamName: input.teamName,
- laneId: 'primary',
- runId: input.runId,
- capabilitySnapshotId: 'opencode:stale-capability',
- behaviorFingerprint: null,
- body: {
- runId: input.runId,
- laneId: 'primary',
- teamId: input.teamName,
- teamName: input.teamName,
- projectPath: PROJECT_PATH,
- expectedCapabilitySnapshotId: 'opencode:stale-capability',
- manifestHighWatermark: null,
- expectedMembers: [{ name: 'E2E', model: input.selectedModel }],
- reason: 'production_gate_stale_run_probe',
- },
- cwd: PROJECT_PATH,
- timeoutMs: 30_000,
- });
- return false;
- } catch (error) {
- return error instanceof Error && error.message.includes('capability snapshot mismatch');
- }
-}
-
-function buildCandidateEvidence(input: {
- runId: string;
- teamName: string;
- memberName: string;
- selectedModel: string;
- runtime: OpenCodeBridgeRuntimeSnapshot;
- readinessObservedTools: string[];
- launch: OpenCodeLaunchTeamCommandData;
- reconcile: OpenCodeLaunchTeamCommandData;
- stop: OpenCodeStopTeamCommandData;
- transcriptMessages: number;
- staleRunRejected: boolean;
- appMcpToolsVisible: boolean;
-}): OpenCodeProductionE2EEvidence {
- const now = new Date();
- const createdAt = now.toISOString();
- const sessionId = input.launch.members[input.memberName]?.sessionId ?? 'missing-session';
- const checkpointByName = new Map();
- for (const checkpoint of input.launch.durableCheckpoints ?? []) {
- checkpointByName.set(checkpoint.name, {
- name: checkpoint.name,
- observedAt: checkpoint.observedAt,
- });
- }
- for (const evidence of input.launch.members[input.memberName]?.evidence ?? []) {
- checkpointByName.set(evidence.kind, {
- name: evidence.kind,
- observedAt: evidence.observedAt,
- });
- }
- for (const name of OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS) {
- checkpointByName.set(name, checkpointByName.get(name) ?? { name, observedAt: createdAt });
- }
-
- return {
- schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
- evidenceId: `live-${input.runId}`,
- createdAt,
- expiresAt: new Date(now.getTime() + OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS).toISOString(),
- version: input.runtime.version ?? 'unknown',
- passed: true,
- artifactPath: path.join(os.tmpdir(), `opencode-production-e2e-${input.runId}.json`),
- binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown',
- capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown',
- selectedModel: input.selectedModel,
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH),
- requiredSignals: {
- ...Object.fromEntries(
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
- ),
- app_mcp_tools_visible: input.appMcpToolsVisible,
- stale_run_rejected: input.staleRunRejected,
- } as OpenCodeProductionE2EEvidence['requiredSignals'],
- mcpTools: {
- requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
- buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
- ),
- observedTools: input.readinessObservedTools,
- },
- launch: {
- runId: input.runId,
- teamId: input.teamName,
- teamLaunchState: 'ready',
- memberCount: 1,
- sessions: [
- {
- memberName: input.memberName,
- sessionId,
- launchState: 'confirmed_alive',
- },
- ],
- durableCheckpoints: Array.from(checkpointByName.values()),
- },
- reconcile: {
- runId: input.reconcile.runId,
- teamLaunchState: 'ready',
- memberCount: Object.keys(input.reconcile.members).length,
- },
- stop: {
- runId: input.stop.runId,
- stopped: true,
- stoppedSessionIds: Object.values(input.stop.members)
- .map((member) => member.sessionId)
- .filter((value): value is string => Boolean(value)),
- },
- logProjection: {
- observed: true,
- projectedMessageCount: input.transcriptMessages,
- },
- };
-}
-
-async function assertExecutable(filePath: string): Promise {
- await fs.access(filePath, fsConstants.X_OK);
-}
-
-function withBunOnPath(pathValue: string): string {
- const bunDir = '/Users/belief/.bun/bin';
- return pathValue.split(path.delimiter).includes(bunDir)
- ? pathValue
- : `${bunDir}${path.delimiter}${pathValue}`;
-}
-
-function createStableBridgeEnv(): NodeJS.ProcessEnv {
- const realHome = os.userInfo().homedir;
- const env = applyOpenCodeAutoUpdatePolicy({ ...process.env });
- return {
- ...env,
- HOME: realHome,
- USERPROFILE: realHome,
- };
-}
diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts
index 5aa66bbe..5d141bb6 100644
--- a/test/main/services/team/OpenCodeReadinessBridge.test.ts
+++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts
@@ -3,18 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
OpenCodeReadinessBridge,
type OpenCodeReadinessBridgeCommandExecutor,
- type OpenCodeProductionE2EEvidenceReadPort,
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge';
-import {
- OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
- buildOpenCodeProjectPathFingerprint,
- type OpenCodeProductionE2EEvidence,
-} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
-import {
- buildOpenCodeCanonicalMcpToolId,
- REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
-} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
import type {
@@ -93,114 +82,6 @@ describe('OpenCodeReadinessBridge', () => {
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
});
- it('blocks production readiness when strict production E2E evidence is missing', async () => {
- const executor = fakeExecutor(
- bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
- );
- const evidence = fakeEvidenceStore(null);
- const bridge = new OpenCodeReadinessBridge(executor, { productionE2eEvidence: evidence });
-
- await expect(
- bridge.checkOpenCodeTeamLaunchReadiness({
- projectPath: '/repo',
- selectedModel: 'openai/gpt-5.4-mini',
- requireExecutionProbe: true,
- launchMode: 'production',
- })
- ).resolves.toMatchObject({
- state: 'e2e_missing',
- launchAllowed: false,
- supportLevel: 'supported_e2e_pending',
- missing: ['OpenCode production launch requires a current production E2E evidence artifact'],
- diagnostics: [
- 'OpenCode production launch requires a current production E2E evidence artifact',
- ],
- });
- expect(evidence.read).toHaveBeenCalledOnce();
- });
-
- it('allows dogfood readiness while surfacing missing production E2E evidence diagnostics', async () => {
- const executor = fakeExecutor(
- bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
- );
- const bridge = new OpenCodeReadinessBridge(executor, {
- productionE2eEvidence: fakeEvidenceStore(null),
- });
-
- await expect(
- bridge.checkOpenCodeTeamLaunchReadiness({
- projectPath: '/repo',
- selectedModel: 'openai/gpt-5.4-mini',
- requireExecutionProbe: true,
- launchMode: 'dogfood',
- })
- ).resolves.toMatchObject({
- state: 'ready',
- launchAllowed: true,
- supportLevel: 'supported_e2e_pending',
- diagnostics: [
- 'OpenCode production launch requires a current production E2E evidence artifact',
- ],
- });
- });
-
- it('keeps production readiness open when evidence matches runtime identity and project context', async () => {
- const executor = fakeExecutor(
- bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
- );
- const evidence = fakeEvidenceStore(productionEvidence());
- const bridge = new OpenCodeReadinessBridge(executor, {
- productionE2eEvidence: evidence,
- });
-
- await expect(
- bridge.checkOpenCodeTeamLaunchReadiness({
- projectPath: '/repo',
- selectedModel: 'openai/gpt-5.4-mini',
- requireExecutionProbe: true,
- launchMode: 'production',
- })
- ).resolves.toMatchObject({
- state: 'ready',
- launchAllowed: true,
- supportLevel: 'production_supported',
- diagnostics: [],
- });
- expect(evidence.read).toHaveBeenCalledWith({
- selectedModel: 'openai/gpt-5.4-mini',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
- opencodeVersion: '1.14.19',
- binaryFingerprint: 'bin-1',
- capabilitySnapshotId: 'cap-1',
- });
- });
-
- it('accepts production evidence recorded with a different OpenCode model when runtime identity matches', async () => {
- const executor = fakeExecutor(
- bridgeSuccess(readiness({ state: 'ready', launchAllowed: true }))
- );
- const evidence = fakeEvidenceStore(
- productionEvidence({ selectedModel: 'opencode/minimax-m2.5-free' })
- );
- const bridge = new OpenCodeReadinessBridge(executor, {
- productionE2eEvidence: evidence,
- });
-
- await expect(
- bridge.checkOpenCodeTeamLaunchReadiness({
- projectPath: '/repo',
- selectedModel: 'opencode/nemotron-3-super-free',
- requireExecutionProbe: true,
- launchMode: 'production',
- })
- ).resolves.toMatchObject({
- state: 'ready',
- launchAllowed: true,
- supportLevel: 'production_supported',
- diagnostics: [],
- });
- });
-
it('routes state-changing launch commands through the guarded command service when configured', async () => {
const executor = fakeExecutor(
bridgeFailure('internal_error', 'direct bridge must not run', [])
@@ -231,7 +112,6 @@ describe('OpenCodeReadinessBridge', () => {
await expect(
bridge.launchOpenCodeTeam({
- mode: 'dogfood',
runId: 'run-1',
laneId: 'primary',
teamId: 'team-a',
@@ -271,19 +151,6 @@ function fakeExecutor(
};
}
-function fakeEvidenceStore(
- evidence: OpenCodeProductionE2EEvidence | null
-): OpenCodeProductionE2EEvidenceReadPort & { read: ReturnType } {
- return {
- read: vi.fn(async () => ({
- ok: true,
- evidence,
- artifactPath: '/tmp/opencode-production-e2e.json',
- diagnostics: [],
- })),
- };
-}
-
function bridgeSuccess(
data: OpenCodeTeamLaunchReadiness
): OpenCodeBridgeSuccess {
@@ -379,65 +246,3 @@ function readiness(
...overrides,
};
}
-
-function productionEvidence(
- overrides: Partial = {}
-): OpenCodeProductionE2EEvidence {
- const createdAt = new Date().toISOString();
- const sessionId = 'session-1';
- const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
- buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
- );
- return {
- schemaVersion: 1,
- evidenceId: 'e2e-1',
- createdAt,
- expiresAt: new Date(Date.now() + 60_000).toISOString(),
- version: '1.14.19',
- passed: true,
- artifactPath: '/tmp/opencode-production-e2e.json',
- binaryFingerprint: 'bin-1',
- capabilitySnapshotId: 'cap-1',
- selectedModel: 'openai/gpt-5.4-mini',
- projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'),
- requiredSignals: Object.fromEntries(
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
- ) as OpenCodeProductionE2EEvidence['requiredSignals'],
- mcpTools: {
- requiredTools: requiredToolIds,
- observedTools: requiredToolIds,
- },
- launch: {
- runId: 'run-1',
- teamId: 'team-a',
- teamLaunchState: 'ready',
- memberCount: 1,
- sessions: [
- {
- memberName: 'Dev',
- sessionId,
- launchState: 'confirmed_alive',
- },
- ],
- durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
- name,
- observedAt: createdAt,
- })),
- },
- reconcile: {
- runId: 'run-1',
- teamLaunchState: 'ready',
- memberCount: 1,
- },
- stop: {
- runId: 'run-1',
- stopped: true,
- stoppedSessionIds: [sessionId],
- },
- logProjection: {
- observed: true,
- projectedMessageCount: 1,
- },
- ...overrides,
- };
-}
diff --git a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts
index c05b26d3..7c67dd1c 100644
--- a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts
+++ b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts
@@ -1,17 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import { createEmptyEndpointMap } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
-import {
- OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
-} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
import {
OpenCodeTeamLaunchReadinessService,
type OpenCodeApiCapabilityPort,
type OpenCodeModelExecutionProbePort,
type OpenCodeMcpToolProofPort,
- type OpenCodeProductionE2EEvidencePort,
type OpenCodeRuntimeInventory,
type OpenCodeRuntimeInventoryPort,
type OpenCodeRuntimeStoreReadinessPort,
@@ -23,7 +18,6 @@ import type {
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
import type { OpenCodeMcpToolProof } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
import type { RuntimeStoreReadinessCheck } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
-import type { OpenCodeProductionE2EEvidence } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy';
describe('OpenCodeTeamLaunchReadinessService', () => {
it('returns not_installed before probing deeper runtime dependencies', async () => {
@@ -84,16 +78,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
});
});
- it('blocks capability-compatible versions until production E2E evidence exists', async () => {
- const ports = createPorts({
- evidence: null,
- });
+ it('does not require project-specific E2E evidence before runtime readiness checks', async () => {
+ const ports = createPorts();
await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
- state: 'e2e_missing',
- launchAllowed: false,
- supportLevel: 'supported_e2e_pending',
- missing: ['OpenCode version is capability-compatible but production E2E evidence is missing'],
+ state: 'ready',
+ launchAllowed: true,
+ supportLevel: 'production_supported',
});
});
@@ -159,46 +150,10 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
});
});
- it('fails closed behind adapter feature gate after all runtime evidence is healthy', async () => {
+ it('allows launch when inventory, capabilities, stores, MCP and model probe are healthy', async () => {
const ports = createPorts();
- await expect(
- service(ports, { adapterEnabled: false }).check(readinessInput())
- ).resolves.toMatchObject({
- state: 'adapter_disabled',
- launchAllowed: false,
- missing: ['OpenCode team launch adapter is disabled by feature gate'],
- });
- expect(ports.inventory.probe).not.toHaveBeenCalled();
- });
-
- it('allows dogfood launch to continue without production E2E evidence after runtime checks pass', async () => {
- const ports = createPorts({ evidence: null });
-
- await expect(
- service(ports, { launchMode: 'dogfood' }).check(
- readinessInput({ requireExecutionProbe: true })
- )
- ).resolves.toMatchObject({
- state: 'ready',
- launchAllowed: true,
- supportLevel: 'supported_e2e_pending',
- requiredToolsPresent: true,
- runtimeStoresReady: true,
- diagnostics: [
- 'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.',
- ],
- });
- expect(ports.mcpTools.prove).toHaveBeenCalled();
- expect(ports.modelExecution.verify).toHaveBeenCalled();
- });
-
- it('allows launch only when inventory, capabilities, E2E, stores, MCP and model probe are healthy', async () => {
- const ports = createPorts();
-
- await expect(
- service(ports, { adapterEnabled: true }).check(readinessInput())
- ).resolves.toMatchObject({
+ await expect(service(ports).check(readinessInput())).resolves.toMatchObject({
state: 'ready',
launchAllowed: true,
modelId: 'openai/gpt-5.4-mini',
@@ -218,20 +173,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => {
});
});
-function service(
- ports: ReturnType,
- options: { adapterEnabled?: boolean; launchMode?: 'disabled' | 'dogfood' | 'production' } = {}
-): OpenCodeTeamLaunchReadinessService {
+function service(ports: ReturnType): OpenCodeTeamLaunchReadinessService {
return new OpenCodeTeamLaunchReadinessService(
ports.inventory,
ports.capabilities,
ports.mcpTools,
ports.runtimeStores,
- ports.modelExecution,
- ports.e2eEvidence,
- options.launchMode
- ? { launchMode: options.launchMode }
- : { adapterEnabled: options.adapterEnabled ?? true }
+ ports.modelExecution
);
}
@@ -261,7 +209,6 @@ function createPorts(
reason: string | null;
diagnostics: string[];
};
- evidence?: OpenCodeProductionE2EEvidence | null;
} = {}
): {
inventory: OpenCodeRuntimeInventoryPort & { probe: ReturnType };
@@ -269,7 +216,6 @@ function createPorts(
mcpTools: OpenCodeMcpToolProofPort & { prove: ReturnType };
runtimeStores: OpenCodeRuntimeStoreReadinessPort & { check: ReturnType };
modelExecution: OpenCodeModelExecutionProbePort & { verify: ReturnType };
- e2eEvidence: OpenCodeProductionE2EEvidencePort & { read: ReturnType };
} {
return {
inventory: {
@@ -287,9 +233,6 @@ function createPorts(
modelExecution: {
verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()),
},
- e2eEvidence: {
- read: vi.fn(async () => (overrides.evidence === undefined ? evidence() : overrides.evidence)),
- },
};
}
@@ -373,60 +316,3 @@ function modelProbe() {
diagnostics: [],
};
}
-
-function evidence(): OpenCodeProductionE2EEvidence {
- const createdAt = new Date().toISOString();
- const sessionId = 'session-1';
- const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`);
- return {
- schemaVersion: 1,
- evidenceId: 'e2e-1',
- createdAt,
- expiresAt: new Date(Date.now() + 60_000).toISOString(),
- version: '1.14.19',
- passed: true,
- artifactPath: '/tmp/opencode-e2e',
- binaryFingerprint: 'version:1.14.19',
- capabilitySnapshotId: 'cap-1',
- selectedModel: 'openai/gpt-5.4-mini',
- projectPathFingerprint: 'project-a',
- requiredSignals: Object.fromEntries(
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true])
- ) as OpenCodeProductionE2EEvidence['requiredSignals'],
- mcpTools: {
- requiredTools: requiredToolIds,
- observedTools: requiredToolIds,
- },
- launch: {
- runId: 'run-1',
- teamId: 'team-a',
- teamLaunchState: 'ready',
- memberCount: 1,
- sessions: [
- {
- memberName: 'Dev',
- sessionId,
- launchState: 'confirmed_alive',
- },
- ],
- durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
- name,
- observedAt: createdAt,
- })),
- },
- reconcile: {
- runId: 'run-1',
- teamLaunchState: 'ready',
- memberCount: 1,
- },
- stop: {
- runId: 'run-1',
- stopped: true,
- stoppedSessionIds: [sessionId],
- },
- logProjection: {
- observed: true,
- projectedMessageCount: 1,
- },
- };
-}
diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts
index 7ed8cbc1..f40e0ceb 100644
--- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts
+++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts
@@ -56,126 +56,120 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
- it(
- 'creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter',
- async () => {
- const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
- const orchestratorCli =
- process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
- await assertExecutable(orchestratorCli);
+ it('creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', async () => {
+ const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
+ const orchestratorCli =
+ process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
+ await assertExecutable(orchestratorCli);
- const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
- const bridgeEnv = {
- ...createStableBridgeEnv(),
- PATH: withBunOnPath(process.env.PATH ?? ''),
- XDG_DATA_HOME: path.join(tempDir, 'xdg-data'),
- CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
- CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
- };
- const bridgeClient = new OpenCodeBridgeCommandClient({
- binaryPath: orchestratorCli,
- tempDirectory: path.join(tempDir, 'bridge-input'),
- env: bridgeEnv,
- });
- const stateChangingCommands = createStateChangingCommands({
- bridge: bridgeClient,
- controlDir: path.join(tempDir, 'control'),
- });
- const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
- stateChangingCommands,
- timeoutMs: 180_000,
- launchTimeoutMs: 180_000,
- reconcileTimeoutMs: 90_000,
- stopTimeoutMs: 90_000,
- });
- const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, {
- launchMode: 'dogfood',
- });
- const svc = new TeamProvisioningService();
- svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
+ const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
+ const bridgeEnv = {
+ ...createStableBridgeEnv(),
+ PATH: withBunOnPath(process.env.PATH ?? ''),
+ XDG_DATA_HOME: path.join(tempDir, 'xdg-data'),
+ CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
+ CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
+ };
+ const bridgeClient = new OpenCodeBridgeCommandClient({
+ binaryPath: orchestratorCli,
+ tempDirectory: path.join(tempDir, 'bridge-input'),
+ env: bridgeEnv,
+ });
+ const stateChangingCommands = createStateChangingCommands({
+ bridge: bridgeClient,
+ controlDir: path.join(tempDir, 'control'),
+ });
+ const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
+ stateChangingCommands,
+ timeoutMs: 180_000,
+ launchTimeoutMs: 180_000,
+ reconcileTimeoutMs: 90_000,
+ stopTimeoutMs: 90_000,
+ });
+ const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
+ const svc = new TeamProvisioningService();
+ svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
- const teamName = `opencode-team-provisioning-${Date.now()}`;
- const progressEvents: TeamProvisioningProgress[] = [];
+ const teamName = `opencode-team-provisioning-${Date.now()}`;
+ const progressEvents: TeamProvisioningProgress[] = [];
- try {
- const { runId } = await svc.createTeam(
- {
- teamName,
- cwd: PROJECT_PATH,
- providerId: 'opencode',
- model: selectedModel,
- skipPermissions: true,
- members: [
- {
- name: 'alice',
- role: 'Developer',
- providerId: 'opencode',
- model: selectedModel,
- },
- {
- name: 'bob',
- role: 'Reviewer',
- providerId: 'opencode',
- model: selectedModel,
- },
- ],
- },
- (progress) => {
- progressEvents.push(progress);
- }
- );
-
- expect(runId).toBeTruthy();
- const progressDump = progressEvents
- .map((progress) =>
- [
- progress.state,
- progress.message,
- progress.messageSeverity,
- progress.error,
- progress.cliLogsTail,
- ]
- .filter(Boolean)
- .join(' | ')
- )
- .join('\n');
- expect(
- progressEvents.some((progress) =>
- progress.message.includes('OpenCode team launch is ready')
- ),
- progressDump
- ).toBe(true);
-
- const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
- expect(runtimeSnapshot.members.alice).toMatchObject({
- alive: true,
- runtimeModel: selectedModel,
- });
- expect(runtimeSnapshot.members.bob).toMatchObject({
- alive: true,
- runtimeModel: selectedModel,
- });
- await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject(
- {
- lanes: {
- primary: {
- state: 'active',
- },
+ try {
+ const { runId } = await svc.createTeam(
+ {
+ teamName,
+ cwd: PROJECT_PATH,
+ providerId: 'opencode',
+ model: selectedModel,
+ skipPermissions: true,
+ members: [
+ {
+ name: 'alice',
+ role: 'Developer',
+ providerId: 'opencode',
+ model: selectedModel,
},
- }
- );
+ {
+ name: 'bob',
+ role: 'Reviewer',
+ providerId: 'opencode',
+ model: selectedModel,
+ },
+ ],
+ },
+ (progress) => {
+ progressEvents.push(progress);
+ }
+ );
- svc.stopTeam(teamName);
- await waitUntil(async () => {
- const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
- return Object.keys(laneIndex.lanes).length === 0;
- }, 90_000);
- } finally {
- svc.stopTeam(teamName);
- }
- },
- 300_000
- );
+ expect(runId).toBeTruthy();
+ const progressDump = progressEvents
+ .map((progress) =>
+ [
+ progress.state,
+ progress.message,
+ progress.messageSeverity,
+ progress.error,
+ progress.cliLogsTail,
+ ]
+ .filter(Boolean)
+ .join(' | ')
+ )
+ .join('\n');
+ expect(
+ progressEvents.some((progress) =>
+ progress.message.includes('OpenCode team launch is ready')
+ ),
+ progressDump
+ ).toBe(true);
+
+ const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
+ expect(runtimeSnapshot.members.alice).toMatchObject({
+ alive: true,
+ runtimeModel: selectedModel,
+ });
+ expect(runtimeSnapshot.members.bob).toMatchObject({
+ alive: true,
+ runtimeModel: selectedModel,
+ });
+ await expect(
+ readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)
+ ).resolves.toMatchObject({
+ lanes: {
+ primary: {
+ state: 'active',
+ },
+ },
+ });
+
+ svc.stopTeam(teamName);
+ await waitUntil(async () => {
+ const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
+ return Object.keys(laneIndex.lanes).length === 0;
+ }, 90_000);
+ } finally {
+ svc.stopTeam(teamName);
+ }
+ }, 300_000);
});
function createStateChangingCommands(input: {
diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts
index 3b6c9b0b..a95e0033 100644
--- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts
+++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts
@@ -20,7 +20,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
diagnostics: ['OpenCode missing canonical app MCP tool id'],
})
);
- const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
+ const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
await expect(adapter.prepare(launchInput())).resolves.toEqual({
ok: false,
@@ -34,13 +34,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
projectPath: '/repo',
selectedModel: 'openai/gpt-5.4-mini',
requireExecutionProbe: true,
- launchMode: 'production',
});
});
it('uses runtime-only readiness for model-less preflight checks', async () => {
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
- const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
+ const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
await expect(
adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))
@@ -54,7 +53,6 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
projectPath: '/repo',
selectedModel: null,
requireExecutionProbe: false,
- launchMode: undefined,
});
});
@@ -69,7 +67,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
missing: ['OpenCode bridge command timed out'],
})
);
- const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
+ const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
teamLaunchState: 'partial_failure',
@@ -87,25 +85,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
});
});
- it('fails closed when launch mode is disabled', async () => {
- const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
- const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
-
- await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
- ok: false,
- providerId: 'opencode',
- reason: 'opencode_team_launch_disabled',
- retryable: false,
- });
- expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
- });
-
it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => {
const launchOpenCodeTeam = vi.fn();
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
});
- const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
+ const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
const result = await adapter.launch(
launchInput({
@@ -138,7 +123,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
});
- const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
+ const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
const result = await adapter.launch(launchInput({ expectedMembers: [] }));
@@ -187,8 +172,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
capabilitySnapshotId: 'cap-1',
})),
launchOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
@@ -257,8 +241,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
const result = await adapter.launch({
@@ -393,8 +376,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
reconcileOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
const result = await adapter.reconcile({
@@ -488,8 +470,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
capabilitySnapshotId: 'cap-1',
})),
launchOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
const result = await adapter.launch(launchInput());
@@ -539,8 +520,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
const result = await adapter.launch(launchInput());
@@ -576,8 +556,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
const result = await adapter.launch(launchInput());
@@ -588,7 +567,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
runtimeAlive: false,
livenessKind: 'runtime_process_candidate',
runtimePid: 123,
- runtimeDiagnostic: 'OpenCode runtime pid reported by bridge without local process verification',
+ runtimeDiagnostic:
+ 'OpenCode runtime pid reported by bridge without local process verification',
});
});
@@ -613,8 +593,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
const result = await adapter.launch(launchInput());
@@ -663,8 +642,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
capabilitySnapshotId: 'cap-1',
})),
launchOpenCodeTeam,
- }),
- { launchMode: 'dogfood' }
+ })
);
const result = await adapter.launch(
diff --git a/test/main/services/team/OpenCodeVersionPolicy.test.ts b/test/main/services/team/OpenCodeVersionPolicy.test.ts
index 991cacfc..cdf95be1 100644
--- a/test/main/services/team/OpenCodeVersionPolicy.test.ts
+++ b/test/main/services/team/OpenCodeVersionPolicy.test.ts
@@ -10,7 +10,6 @@ import {
type OpenCodeApiEndpointKey,
} from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities';
import {
- assertOpenCodeProductionE2EGate,
buildOpenCodeBinaryFingerprint,
evaluateOpenCodeSupport,
parseOpenCodeSemver,
@@ -19,12 +18,6 @@ import {
type OpenCodeCompatibilitySnapshot,
type OpenCodeRouteCompatibilityCache,
} from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy';
-import {
- OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS,
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS,
- type OpenCodeProductionE2EEvidence,
-} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence';
-import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
describe('OpenCodeVersionPolicy', () => {
let tempDir: string;
@@ -58,7 +51,6 @@ describe('OpenCodeVersionPolicy', () => {
evaluateOpenCodeSupport({
version: '1.4.0',
capabilities: readyCapabilities(),
- evidence: passingEvidence(),
})
).toMatchObject({
supported: false,
@@ -70,7 +62,6 @@ describe('OpenCodeVersionPolicy', () => {
evaluateOpenCodeSupport({
version: '1.14.19-beta.1',
capabilities: readyCapabilities(),
- evidence: passingEvidence(),
})
).toMatchObject({
supported: false,
@@ -79,12 +70,11 @@ describe('OpenCodeVersionPolicy', () => {
});
});
- it('requires capabilities and production E2E evidence before production support', () => {
+ it('requires capabilities before support', () => {
expect(
evaluateOpenCodeSupport({
version: '1.14.19',
capabilities: missingCapabilities(['POST permission reply route']),
- evidence: passingEvidence(),
})
).toMatchObject({
supported: false,
@@ -96,23 +86,6 @@ describe('OpenCodeVersionPolicy', () => {
evaluateOpenCodeSupport({
version: '1.14.19',
capabilities: readyCapabilities(),
- evidence: null,
- })
- ).toMatchObject({
- supported: false,
- supportLevel: 'supported_e2e_pending',
- diagnostics: [
- 'OpenCode version is capability-compatible but production E2E evidence is missing',
- ],
- });
- });
-
- it('accepts supported version only when capabilities and E2E evidence pass', () => {
- expect(
- evaluateOpenCodeSupport({
- version: '1.14.19',
- capabilities: readyCapabilities(),
- evidence: passingEvidence(),
})
).toMatchObject({
supported: true,
@@ -121,31 +94,16 @@ describe('OpenCodeVersionPolicy', () => {
});
});
- it('rejects stale or incomplete production E2E evidence', () => {
+ it('accepts supported version when capabilities pass', () => {
expect(
- assertOpenCodeProductionE2EGate({
- evidence: passingEvidence({ version: '1.14.18' }),
- testedVersion: '1.14.19',
+ evaluateOpenCodeSupport({
+ version: '1.14.19',
+ capabilities: readyCapabilities(),
})
).toMatchObject({
- ok: false,
- diagnostics: expect.arrayContaining([
- 'OpenCode production E2E evidence version 1.14.18 does not match tested version 1.14.19',
- ]),
- });
-
- expect(
- assertOpenCodeProductionE2EGate({
- evidence: passingEvidence({
- requiredSignals: requiredSignals({ canonical_log_projection_observed: false }),
- }),
- testedVersion: '1.14.19',
- })
- ).toMatchObject({
- ok: false,
- diagnostics: expect.arrayContaining([
- 'OpenCode production E2E evidence is missing signals: canonical_log_projection_observed',
- ]),
+ supported: true,
+ supportLevel: 'production_supported',
+ diagnostics: [],
});
});
@@ -260,76 +218,6 @@ function missingCapabilities(missing: string[]) {
};
}
-function passingEvidence(
- overrides: Partial = {}
-): OpenCodeProductionE2EEvidence {
- const createdAt = new Date().toISOString();
- const expiresAt = new Date(Date.now() + 60_000).toISOString();
- const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`);
- const durableCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({
- name,
- observedAt: createdAt,
- }));
-
- return {
- schemaVersion: 1,
- evidenceId: 'e2e-1',
- createdAt,
- expiresAt,
- version: '1.14.19',
- passed: true,
- artifactPath: '/tmp/opencode-e2e',
- binaryFingerprint: 'version:1.14.19',
- capabilitySnapshotId: 'cap-1',
- selectedModel: 'openai/gpt-5.4-mini',
- projectPathFingerprint: 'project-a',
- requiredSignals: requiredSignals(),
- mcpTools: {
- requiredTools: requiredToolIds,
- observedTools: requiredToolIds,
- },
- launch: {
- runId: 'run-1',
- teamId: 'team-a',
- teamLaunchState: 'ready',
- memberCount: 1,
- sessions: [
- {
- memberName: 'Dev',
- sessionId: 'ses-1',
- launchState: 'confirmed_alive',
- },
- ],
- durableCheckpoints,
- },
- reconcile: {
- runId: 'run-1',
- teamLaunchState: 'ready',
- memberCount: 1,
- },
- stop: {
- runId: 'run-1',
- stopped: true,
- stoppedSessionIds: ['ses-1'],
- },
- logProjection: {
- observed: true,
- projectedMessageCount: 1,
- },
- ...overrides,
- };
-}
-
-function requiredSignals(
- overrides: Partial<
- Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean>
- > = {}
-) {
- return Object.fromEntries(
- OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true])
- ) as OpenCodeProductionE2EEvidence['requiredSignals'];
-}
-
function compatibilitySnapshot(
overrides: Partial
): OpenCodeCompatibilitySnapshot {
@@ -349,7 +237,6 @@ function compatibilitySnapshot(
supported: true,
supportLevel: 'production_supported',
apiCapabilities: readyCapabilities(),
- testedEvidencePath: '/tmp/opencode-e2e',
diagnostics: [],
...overrides,
};
diff --git a/test/main/services/team/TeamLaunchStateEvaluator.test.ts b/test/main/services/team/TeamLaunchStateEvaluator.test.ts
index dff9760f..ddfb4168 100644
--- a/test/main/services/team/TeamLaunchStateEvaluator.test.ts
+++ b/test/main/services/team/TeamLaunchStateEvaluator.test.ts
@@ -77,6 +77,7 @@ describe('TeamLaunchStateEvaluator', () => {
confirmedCount: 0,
pendingCount: 2,
failedCount: 0,
+ skippedCount: 0,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
@@ -86,6 +87,72 @@ describe('TeamLaunchStateEvaluator', () => {
});
});
+ it('keeps skipped members terminal and out of pending counts', () => {
+ const summary = summarizePersistedLaunchMembers(['alice', 'bob'], {
+ alice: {
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ },
+ bob: {
+ launchState: 'confirmed_alive',
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ },
+ } as any);
+
+ expect(summary).toMatchObject({
+ confirmedCount: 1,
+ pendingCount: 0,
+ failedCount: 0,
+ skippedCount: 1,
+ });
+ });
+
+ it('does not preserve runtimeAlive for skipped persisted members', () => {
+ const snapshot = normalizePersistedLaunchSnapshot('demo', {
+ version: 2,
+ teamName: 'demo',
+ updatedAt: '2026-04-23T00:00:00.000Z',
+ launchPhase: 'finished',
+ expectedMembers: ['alice'],
+ members: {
+ alice: {
+ name: 'alice',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ livenessKind: 'runtime_process',
+ lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
+ },
+ },
+ });
+
+ expect(snapshot?.members.alice).toMatchObject({
+ launchState: 'skipped_for_launch',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ agentToolAccepted: false,
+ skippedForLaunch: true,
+ });
+
+ const statuses = snapshotToMemberSpawnStatuses(snapshot!);
+ expect(statuses.alice).toMatchObject({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ agentToolAccepted: false,
+ skippedForLaunch: true,
+ });
+ });
+
it('counts registered-only persisted liveness as no-runtime pending', () => {
const summary = summarizePersistedLaunchMembers(['alice'], {
alice: {
diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts
index 554cc524..9584ca92 100644
--- a/test/main/services/team/TeamProvisioningService.test.ts
+++ b/test/main/services/team/TeamProvisioningService.test.ts
@@ -1550,6 +1550,191 @@ describe('TeamProvisioningService', () => {
expect(restartMessage).toContain('Their workflow: Use the updated checklist');
});
+ it('retries a failed teammate without live runtime by resetting spawn status to spawning', async () => {
+ const svc = new TeamProvisioningService();
+ const run = createMemberSpawnRun({
+ teamName: 'codex-team',
+ expectedMembers: ['bob'],
+ memberSpawnStatuses: new Map([
+ [
+ 'bob',
+ createMemberSpawnStatusEntry({
+ status: 'error',
+ launchState: 'failed_to_start',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: 'Teammate "bob" failed to start: spawn failed',
+ error: 'Teammate "bob" failed to start: spawn failed',
+ agentToolAccepted: false,
+ firstSpawnAcceptedAt: undefined,
+ }),
+ ],
+ ]),
+ });
+ run.child = { pid: 111 };
+ run.processKilled = false;
+ run.cancelRequested = false;
+
+ const sendMessageToRun = vi.fn(async () => {});
+ (svc as any).sendMessageToRun = sendMessageToRun;
+ (svc as any).configReader = {
+ getConfig: vi.fn(async () => ({
+ name: 'Codex Team',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ })),
+ };
+ (svc as any).membersMetaStore = {
+ getMembers: vi.fn(async () => [
+ {
+ name: 'bob',
+ role: 'Developer',
+ providerId: 'codex',
+ model: 'gpt-5.2',
+ effort: 'medium',
+ agentType: 'general-purpose',
+ },
+ ]),
+ };
+ (svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
+ (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
+ (svc as any).aliveRunByTeam.set('codex-team', run.runId);
+ (svc as any).runs.set(run.runId, run);
+
+ await svc.restartMember('codex-team', 'bob');
+
+ expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
+ status: 'spawning',
+ launchState: 'starting',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ hardFailureReason: undefined,
+ error: undefined,
+ agentToolAccepted: false,
+ });
+ expect(run.pendingMemberRestarts.has('bob')).toBe(true);
+ expect(sendMessageToRun).toHaveBeenCalledTimes(1);
+ expect(sendMessageToRun).toHaveBeenCalledWith(
+ run,
+ expect.stringContaining('Teammate "bob" with role "Developer" was restarted from the UI.')
+ );
+ });
+
+ it('skips a failed teammate for the current launch without marking it alive', async () => {
+ const svc = new TeamProvisioningService();
+ const run = createMemberSpawnRun({
+ teamName: 'codex-team',
+ expectedMembers: ['bob'],
+ memberSpawnStatuses: new Map([
+ [
+ 'bob',
+ createMemberSpawnStatusEntry({
+ status: 'error',
+ launchState: 'failed_to_start',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: 'Teammate "bob" failed to start: spawn failed',
+ error: 'Teammate "bob" failed to start: spawn failed',
+ agentToolAccepted: false,
+ firstSpawnAcceptedAt: undefined,
+ }),
+ ],
+ ]),
+ });
+ run.child = { pid: 111 };
+ run.processKilled = false;
+ run.cancelRequested = false;
+ run.isLaunch = true;
+
+ const sendMessageToRun = vi.fn(async () => {});
+ (svc as any).sendMessageToRun = sendMessageToRun;
+ (svc as any).configReader = {
+ getConfig: vi.fn(async () => ({
+ name: 'Codex Team',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ })),
+ };
+ (svc as any).membersMetaStore = {
+ getMembers: vi.fn(async () => [
+ {
+ name: 'bob',
+ role: 'Developer',
+ providerId: 'codex',
+ model: 'gpt-5.2',
+ effort: 'medium',
+ agentType: 'general-purpose',
+ },
+ ]),
+ };
+ (svc as any).aliveRunByTeam.set('codex-team', run.runId);
+ (svc as any).runs.set(run.runId, run);
+
+ await svc.skipMemberForLaunch('codex-team', 'bob');
+
+ expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ hardFailureReason: undefined,
+ error: undefined,
+ agentToolAccepted: false,
+ });
+ expect(run.pendingMemberRestarts.has('bob')).toBe(false);
+ expect(sendMessageToRun).toHaveBeenCalledWith(
+ run,
+ expect.stringContaining('Teammate "bob" was skipped for this launch')
+ );
+ });
+
+ it('rejects skipping a failed teammate while a retry is already in progress', async () => {
+ const svc = new TeamProvisioningService();
+ const run = createMemberSpawnRun({
+ teamName: 'codex-team',
+ expectedMembers: ['bob'],
+ memberSpawnStatuses: new Map([
+ [
+ 'bob',
+ createMemberSpawnStatusEntry({
+ status: 'error',
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ hardFailureReason: 'spawn failed',
+ error: 'spawn failed',
+ }),
+ ],
+ ]),
+ });
+ run.child = { pid: 111 };
+ run.processKilled = false;
+ run.cancelRequested = false;
+ run.pendingMemberRestarts.set('bob', {
+ requestedAt: new Date().toISOString(),
+ desired: { name: 'bob', role: 'Developer' },
+ });
+
+ (svc as any).configReader = {
+ getConfig: vi.fn(async () => ({
+ name: 'Codex Team',
+ members: [
+ { name: 'team-lead', agentType: 'team-lead' },
+ { name: 'bob', role: 'Developer' },
+ ],
+ })),
+ };
+ (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
+ (svc as any).aliveRunByTeam.set('codex-team', run.runId);
+ (svc as any).runs.set(run.runId, run);
+
+ await expect(svc.skipMemberForLaunch('codex-team', 'bob')).rejects.toThrow(
+ 'already in progress'
+ );
+ });
+
it('does not let removed base-member metadata override a suffixed teammate during restart', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
@@ -7106,7 +7291,8 @@ describe('TeamProvisioningService', () => {
{
alive: false,
livenessKind: 'runtime_process_candidate',
- runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
+ runtimeDiagnostic:
+ 'OpenCode runtime pid is alive, but process identity is unverified',
runtimeDiagnosticSeverity: 'warning',
},
],
@@ -7337,6 +7523,117 @@ describe('TeamProvisioningService', () => {
});
});
+ it('does not resurrect a skipped teammate when live runtime metadata is strong', async () => {
+ const svc = new TeamProvisioningService();
+ (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
+ async () =>
+ new Map([
+ [
+ 'bob',
+ {
+ alive: true,
+ livenessKind: 'runtime_process',
+ pid: 123,
+ providerId: 'codex',
+ },
+ ],
+ ])
+ );
+
+ const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('codex-team', {
+ bob: createMemberSpawnStatusEntry({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ agentToolAccepted: false,
+ skipReason: 'Skipped by user after launch failure: spawn failed',
+ }),
+ });
+
+ expect(result.bob).toMatchObject({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ error: undefined,
+ livenessSource: undefined,
+ });
+ });
+
+ it('does not resurrect a skipped teammate during spawn status audit', async () => {
+ const run = createMemberSpawnRun({
+ expectedMembers: ['bob'],
+ memberSpawnStatuses: new Map([
+ [
+ 'bob',
+ createMemberSpawnStatusEntry({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ skipReason: 'Skipped by user after launch failure: spawn failed',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ firstSpawnAcceptedAt: undefined,
+ }),
+ ],
+ ]),
+ });
+ const svc = new TeamProvisioningService();
+ (svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set(['bob']));
+ (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['bob']));
+
+ await (svc as any).auditMemberSpawnStatuses(run);
+
+ expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ });
+ });
+
+ it('does not convert a skipped teammate to failed during final missing-member reconciliation', async () => {
+ const run = createMemberSpawnRun({
+ expectedMembers: ['bob'],
+ memberSpawnStatuses: new Map([
+ [
+ 'bob',
+ createMemberSpawnStatusEntry({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ skipReason: 'Skipped by user after launch failure: spawn failed',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ firstSpawnAcceptedAt: undefined,
+ }),
+ ],
+ ]),
+ });
+ const svc = new TeamProvisioningService();
+ (svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set());
+
+ await (svc as any).finalizeMissingRegisteredMembersAsFailed(run);
+
+ expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ });
+ });
+
it('does not downgrade an already-online teammate when waiting is reported later', () => {
const run = createMemberSpawnRun({
memberSpawnStatuses: new Map([
diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts
index 112abc53..5f9895c8 100644
--- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts
+++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts
@@ -473,11 +473,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
return {
ok: false as const,
providerId: 'opencode' as const,
- reason: 'e2e_missing',
+ reason: 'model_unavailable',
retryable: false,
- diagnostics: [
- 'OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
- ],
+ diagnostics: ['Selected model opencode/nemotron-3-super-free is not available'],
warnings: [],
};
}
@@ -529,7 +527,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
'Selected model opencode/minimax-m2.5-free verified for launch.'
);
expect(result.message).toBe(
- 'Selected model opencode/nemotron-3-super-free is unavailable. OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free'
+ 'Selected model opencode/nemotron-3-super-free is unavailable. Selected model opencode/nemotron-3-super-free is not available'
);
});
@@ -687,56 +685,6 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
);
});
- it('reports missing OpenCode project evidence as a provider note instead of model failures', async () => {
- const projectEvidenceDiagnostic =
- 'OpenCode production E2E evidence artifact has no entry for the current working directory';
- const projectEvidenceNote =
- 'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.';
- const prepare = vi.fn(async () => ({
- ok: false as const,
- providerId: 'opencode' as const,
- reason: 'e2e_missing',
- retryable: false,
- diagnostics: [projectEvidenceDiagnostic],
- warnings: [],
- }));
- const registry = new TeamRuntimeAdapterRegistry([
- {
- providerId: 'opencode',
- prepare,
- launch: vi.fn(),
- reconcile: vi.fn(),
- stop: vi.fn(),
- } as any,
- ]);
- const svc = new TeamProvisioningService();
- svc.setRuntimeAdapterRegistry(registry);
-
- const result = await svc.prepareForProvisioning(tempRoot, {
- providerId: 'opencode',
- forceFresh: true,
- modelIds: ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'],
- modelVerificationMode: 'compatibility',
- });
-
- expect(result.ready).toBe(true);
- expect(result.message).toBe('CLI is ready to launch (see notes)');
- expect(result.details).toEqual([
- 'Selected model opencode/minimax-m2.5-free verified for launch.',
- 'Selected model opencode/ling-2.6-flash-free verified for launch.',
- ]);
- expect(result.warnings).toEqual([projectEvidenceNote]);
- expect(result.message).not.toContain('unavailable');
- expect(prepare).toHaveBeenCalledTimes(1);
- expect(prepare).toHaveBeenCalledWith(
- expect.objectContaining({
- providerId: 'opencode',
- model: undefined,
- runtimeOnly: true,
- })
- );
- });
-
it('treats retryable OpenCode compatibility failures as blocking selected-model diagnostics', async () => {
const prepare = vi.fn(async () => ({
ok: false as const,
diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts
index 66fe3c1e..20607fb2 100644
--- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts
+++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts
@@ -613,6 +613,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(
'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.'
);
+ expect(prompt).toContain('retry tool search at most once');
+ expect(prompt).toContain('Do NOT keep searching for member_briefing');
});
it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => {
diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts
index 14648d1e..d7e4cb84 100644
--- a/test/main/utils/electronUserDataMigration.test.ts
+++ b/test/main/utils/electronUserDataMigration.test.ts
@@ -99,7 +99,6 @@ describe('electron userData migration', () => {
['mcp-server/1.3.0/package.json', '{"type":"module"}'],
['opencode-bridge/command-ledger.json', '{"commands":[]}'],
['opencode-bridge/command-leases.json', '{"leases":[]}'],
- ['opencode-bridge/production-e2e-evidence.json', '{"ok":true}'],
['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'],
['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'],
['future-feature/state.json', '{"kept":true}'],
diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts
index 3bd94ee2..50929675 100644
--- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts
+++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts
@@ -746,7 +746,7 @@ describe('TeamModelSelector disabled Codex models', () => {
supported: true,
authenticated: true,
statusMessage: 'OpenCode team launch is gated',
- detailMessage: 'OpenCode production E2E evidence is missing',
+ detailMessage: 'OpenCode runtime store needs recovery',
capabilities: { teamLaunch: false },
models: [],
},
@@ -773,7 +773,7 @@ describe('TeamModelSelector disabled Codex models', () => {
);
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
expect(openCodeButton?.getAttribute('title')).toContain(
- 'OpenCode production E2E evidence is missing'
+ 'OpenCode runtime store needs recovery'
);
expect(openCodeButton?.textContent).toContain('Gate');
diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts
index f3fc17d3..a9af3989 100644
--- a/test/renderer/components/team/TeamProvisioningBanner.test.ts
+++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts
@@ -167,7 +167,10 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
cliLogsTail: '',
assistantOutput: '',
};
- storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record;
+ storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record<
+ string,
+ unknown
+ >;
storeState.memberSpawnStatusesByTeam['northstar-core'] = {
alice: { status: 'waiting', launchState: 'starting' },
bob: { status: 'waiting', launchState: 'starting' },
@@ -281,6 +284,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
pendingCount: 3,
failedCount: 0,
runtimeAlivePendingCount: 3,
+ runtimeProcessPendingCount: 3,
},
source: 'merged',
};
diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts
index 6fe64e72..5f82f2cd 100644
--- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts
+++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts
@@ -327,16 +327,13 @@ describe('ProvisioningProviderStatusList', () => {
{
providerId: 'opencode',
status: 'failed',
- details: [
- 'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
- ],
+ details: ['nemotron-3-super-free - unavailable - selected model is not available'],
},
],
})
).toEqual({
state: 'failed',
- message:
- 'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free',
+ message: 'nemotron-3-super-free - unavailable - selected model is not available',
});
});
diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts
index bdfa9007..46a183da 100644
--- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts
+++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts
@@ -256,68 +256,6 @@ describe('runProviderPrepareDiagnostics', () => {
);
});
- it('shows missing OpenCode project evidence as a provider note instead of model failures', async () => {
- const projectEvidenceNote =
- 'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.';
- const prepareProvisioning = vi
- .fn<
- (
- cwd?: string,
- providerId?: TeamProviderId,
- providerIds?: TeamProviderId[],
- selectedModels?: string[],
- limitContext?: boolean,
- modelVerificationMode?: 'compatibility' | 'deep'
- ) => Promise
- >()
- .mockResolvedValue({
- ready: true,
- message: 'CLI is ready to launch (see notes)',
- details: [
- 'Selected model opencode/minimax-m2.5-free verified for launch.',
- 'Selected model opencode/ling-2.6-flash-free verified for launch.',
- ],
- warnings: [projectEvidenceNote],
- });
-
- const result = await runProviderPrepareDiagnostics({
- cwd: '/tmp/project',
- providerId: 'opencode',
- selectedModelIds: ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'],
- prepareProvisioning,
- });
-
- expect(result.status).toBe('notes');
- expect(result.details).toEqual([
- projectEvidenceNote,
- 'minimax-m2.5-free - verified',
- 'ling-2.6-flash-free - verified',
- ]);
- expect(result.details.filter((detail) => detail === projectEvidenceNote)).toHaveLength(1);
- expect(result.warnings).toEqual([projectEvidenceNote]);
- expect(result.modelResultsById).toEqual({
- 'opencode/minimax-m2.5-free': {
- status: 'ready',
- line: 'minimax-m2.5-free - verified',
- warningLine: null,
- },
- 'opencode/ling-2.6-flash-free': {
- status: 'ready',
- line: 'ling-2.6-flash-free - verified',
- warningLine: null,
- },
- });
- expect(prepareProvisioning).toHaveBeenCalledTimes(1);
- expect(prepareProvisioning).toHaveBeenCalledWith(
- '/tmp/project',
- 'opencode',
- ['opencode'],
- ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'],
- undefined,
- 'compatibility'
- );
- });
-
it('normalizes raw Codex API error envelopes into a clean model reason', async () => {
const prepareProvisioning = vi.fn<
(
diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts
index b97dc859..4e964b65 100644
--- a/test/renderer/components/team/members/MemberCard.test.ts
+++ b/test/renderer/components/team/members/MemberCard.test.ts
@@ -2,7 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
+import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({
@@ -56,6 +56,33 @@ const currentTask: TeamTaskWithKanban = {
status: 'in_progress',
} as unknown as TeamTaskWithKanban;
+const failedSpawnEntry: MemberSpawnStatusEntry = {
+ status: 'error',
+ launchState: 'failed_to_start',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: 'spawn failed',
+ agentToolAccepted: false,
+ livenessKind: 'not_found',
+ runtimeDiagnostic: 'spawn failed',
+ runtimeDiagnosticSeverity: 'error',
+ updatedAt: '2026-04-24T12:00:00.000Z',
+};
+
+const skippedSpawnEntry: MemberSpawnStatusEntry = {
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ agentToolAccepted: false,
+ skippedForLaunch: true,
+ skipReason: 'Skipped by user after launch failure: spawn failed',
+ skippedAt: '2026-04-24T12:01:00.000Z',
+ updatedAt: '2026-04-24T12:01:00.000Z',
+};
+
describe('MemberCard starting-state visuals', () => {
afterEach(() => {
document.body.innerHTML = '';
@@ -582,4 +609,309 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve();
});
});
+
+ it('renders retry for failed teammate launches', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member,
+ memberColor: 'blue',
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'error',
+ spawnLaunchState: 'failed_to_start',
+ spawnRuntimeAlive: false,
+ spawnError: 'spawn failed',
+ spawnEntry: failedSpawnEntry,
+ onRestartMember: vi.fn(),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('renders skip for failed teammate launches', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member,
+ memberColor: 'blue',
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'error',
+ spawnLaunchState: 'failed_to_start',
+ spawnRuntimeAlive: false,
+ spawnError: 'spawn failed',
+ spawnEntry: failedSpawnEntry,
+ onSkipMemberForLaunch: vi.fn(),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('retries failed teammate launches without opening the member row', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onClick = vi.fn();
+ let resolveRetry!: () => void;
+ const retryPromise = new Promise((resolve) => {
+ resolveRetry = resolve;
+ });
+ const onRestartMember = vi.fn(() => retryPromise);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member,
+ memberColor: 'blue',
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'error',
+ spawnLaunchState: 'failed_to_start',
+ spawnRuntimeAlive: false,
+ spawnError: 'spawn failed',
+ spawnEntry: failedSpawnEntry,
+ onClick,
+ onRestartMember,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement;
+ expect(button).not.toBeNull();
+
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ });
+
+ expect(onRestartMember).toHaveBeenCalledWith('alice');
+ expect(onClick).not.toHaveBeenCalled();
+ expect(host.querySelector('[aria-label="Retrying teammate"]')).not.toBeNull();
+
+ await act(async () => {
+ resolveRetry();
+ await retryPromise;
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('skips failed teammate launches without opening the member row', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onClick = vi.fn();
+ let resolveSkip!: () => void;
+ const skipPromise = new Promise((resolve) => {
+ resolveSkip = resolve;
+ });
+ const onSkipMemberForLaunch = vi.fn(() => skipPromise);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member,
+ memberColor: 'blue',
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'error',
+ spawnLaunchState: 'failed_to_start',
+ spawnRuntimeAlive: false,
+ spawnError: 'spawn failed',
+ spawnEntry: failedSpawnEntry,
+ onClick,
+ onSkipMemberForLaunch,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement;
+ expect(button).not.toBeNull();
+
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ });
+
+ expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice');
+ expect(onClick).not.toHaveBeenCalled();
+ expect(host.querySelector('[aria-label="Skipping teammate"]')).not.toBeNull();
+
+ await act(async () => {
+ resolveSkip();
+ await skipPromise;
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('keeps retry available and exposes retry errors after rejection', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onRestartMember = vi.fn(async () => {
+ throw new Error('restart failed');
+ });
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member,
+ memberColor: 'blue',
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'error',
+ spawnLaunchState: 'failed_to_start',
+ spawnRuntimeAlive: false,
+ spawnError: 'spawn failed',
+ spawnEntry: failedSpawnEntry,
+ onRestartMember,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement;
+ expect(button).not.toBeNull();
+
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(onRestartMember).toHaveBeenCalledWith('alice');
+ expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
+ expect(host.textContent).toContain('restart failed');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('keeps skip available and exposes skip errors after rejection', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onSkipMemberForLaunch = vi.fn(async () => {
+ throw new Error('skip failed');
+ });
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member,
+ memberColor: 'blue',
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'error',
+ spawnLaunchState: 'failed_to_start',
+ spawnRuntimeAlive: false,
+ spawnError: 'spawn failed',
+ spawnEntry: failedSpawnEntry,
+ onSkipMemberForLaunch,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement;
+ expect(button).not.toBeNull();
+
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice');
+ expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
+ expect(host.textContent).toContain('skip failed');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('shows skipped teammates as skipped and keeps retry available', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member,
+ memberColor: 'blue',
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'skipped',
+ spawnLaunchState: 'skipped_for_launch',
+ spawnRuntimeAlive: false,
+ spawnEntry: skippedSpawnEntry,
+ onRestartMember: vi.fn(),
+ onSkipMemberForLaunch: vi.fn(),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('skipped');
+ expect(host.textContent).toContain('Skipped by user after launch failure');
+ expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
+ expect(host.querySelector('[aria-label="Skip for this launch"]')).toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
});
diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts
index 36ba0fdb..02516601 100644
--- a/test/renderer/components/team/members/MemberList.test.ts
+++ b/test/renderer/components/team/members/MemberList.test.ts
@@ -8,10 +8,45 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
MemberCard: ({
member,
spawnError,
+ spawnStatus,
+ spawnLaunchState,
+ onRestartMember,
+ onSkipMemberForLaunch,
}: {
member: ResolvedTeamMember;
spawnError?: string;
- }) => React.createElement('div', { 'data-testid': `member-${member.name}` }, spawnError ?? ''),
+ spawnStatus?: string;
+ spawnLaunchState?: string;
+ onRestartMember?: (memberName: string) => void;
+ onSkipMemberForLaunch?: (memberName: string) => void;
+ }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': `member-${member.name}` },
+ spawnError ?? '',
+ onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
+ ? React.createElement(
+ 'button',
+ {
+ 'data-testid': `retry-${member.name}`,
+ type: 'button',
+ onClick: () => onRestartMember(member.name),
+ },
+ 'retry'
+ )
+ : null,
+ onSkipMemberForLaunch && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
+ ? React.createElement(
+ 'button',
+ {
+ 'data-testid': `skip-${member.name}`,
+ type: 'button',
+ onClick: () => onSkipMemberForLaunch(member.name),
+ },
+ 'skip'
+ )
+ : null
+ ),
}));
import { MemberList } from '@renderer/components/team/members/MemberList';
@@ -98,4 +133,126 @@ describe('MemberList spawn-status memoization', () => {
await Promise.resolve();
});
});
+
+ it('passes retry callbacks to failed member cards and rerenders when the callback changes', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const members = [member];
+ const firstRestart = vi.fn();
+ const secondRestart = vi.fn();
+ const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberList, {
+ members,
+ isTeamAlive: true,
+ memberSpawnStatuses: spawnStatuses,
+ onRestartMember: firstRestart,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const firstRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement;
+ expect(firstRetry).not.toBeNull();
+
+ await act(async () => {
+ firstRetry.click();
+ await Promise.resolve();
+ });
+
+ expect(firstRestart).toHaveBeenCalledWith('bob');
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberList, {
+ members,
+ isTeamAlive: true,
+ memberSpawnStatuses: spawnStatuses,
+ onRestartMember: secondRestart,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const secondRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement;
+ expect(secondRetry).not.toBeNull();
+
+ await act(async () => {
+ secondRetry.click();
+ await Promise.resolve();
+ });
+
+ expect(secondRestart).toHaveBeenCalledWith('bob');
+ expect(firstRestart).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const members = [member];
+ const firstSkip = vi.fn();
+ const secondSkip = vi.fn();
+ const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberList, {
+ members,
+ isTeamAlive: true,
+ memberSpawnStatuses: spawnStatuses,
+ onSkipMemberForLaunch: firstSkip,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const firstButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement;
+ expect(firstButton).not.toBeNull();
+
+ await act(async () => {
+ firstButton.click();
+ await Promise.resolve();
+ });
+
+ expect(firstSkip).toHaveBeenCalledWith('bob');
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberList, {
+ members,
+ isTeamAlive: true,
+ memberSpawnStatuses: spawnStatuses,
+ onSkipMemberForLaunch: secondSkip,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const secondButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement;
+ expect(secondButton).not.toBeNull();
+
+ await act(async () => {
+ secondButton.click();
+ await Promise.resolve();
+ });
+
+ expect(secondSkip).toHaveBeenCalledWith('bob');
+ expect(firstSkip).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
});
diff --git a/test/renderer/components/team/provisioningSteps.test.ts b/test/renderer/components/team/provisioningSteps.test.ts
index 69689fb8..eae7686f 100644
--- a/test/renderer/components/team/provisioningSteps.test.ts
+++ b/test/renderer/components/team/provisioningSteps.test.ts
@@ -104,4 +104,25 @@ describe('getLaunchJoinMilestonesFromMembers', () => {
expect(milestones.processOnlyAliveCount).toBe(0);
expect(milestones.pendingSpawnCount).toBe(4);
});
+
+ it('counts skipped teammates separately from pending and failed launch members', () => {
+ const milestones = getLaunchJoinMilestonesFromMembers({
+ members,
+ memberSpawnStatuses: {
+ alice: {
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ updatedAt: '2026-04-24T12:00:00.000Z',
+ },
+ },
+ });
+
+ expect(milestones.skippedSpawnCount).toBe(1);
+ expect(milestones.failedSpawnCount).toBe(0);
+ expect(milestones.pendingSpawnCount).toBe(3);
+ });
});
diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts
index 04313f64..6ca20000 100644
--- a/test/renderer/store/teamSlice.test.ts
+++ b/test/renderer/store/teamSlice.test.ts
@@ -29,6 +29,7 @@ const hoisted = vi.hoisted(() => ({
permanentlyDeleteTeam: vi.fn(),
sendMessage: vi.fn(),
restartMember: vi.fn(),
+ skipMemberForLaunch: vi.fn(),
requestReview: vi.fn(),
updateKanban: vi.fn(),
invalidateTaskChangeSummaries: vi.fn(),
@@ -53,6 +54,7 @@ vi.mock('@renderer/api', () => ({
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
sendMessage: hoisted.sendMessage,
restartMember: hoisted.restartMember,
+ skipMemberForLaunch: hoisted.skipMemberForLaunch,
requestReview: hoisted.requestReview,
updateKanban: hoisted.updateKanban,
onProvisioningProgress: hoisted.onProvisioningProgress,
@@ -233,6 +235,7 @@ describe('teamSlice actions', () => {
hoisted.restoreTeam.mockResolvedValue(undefined);
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
hoisted.restartMember.mockResolvedValue(undefined);
+ hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
});
it('maps inbox verify failure to user-friendly text', async () => {
@@ -2537,6 +2540,57 @@ describe('teamSlice actions', () => {
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
});
+ it('skipMemberForLaunch refreshes spawn statuses, runtime snapshot, and team list', async () => {
+ const store = createSliceStore();
+ const refreshTeams = vi.fn(async () => undefined);
+ store.setState({ fetchTeams: refreshTeams });
+ hoisted.getMemberSpawnStatuses.mockResolvedValue({
+ statuses: {
+ alice: createMemberSpawnStatus({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ }),
+ },
+ runId: 'runtime-run',
+ });
+ hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot());
+
+ await store.getState().skipMemberForLaunch('my-team', 'alice');
+
+ expect(hoisted.skipMemberForLaunch).toHaveBeenCalledWith('my-team', 'alice');
+ expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
+ alice: expect.objectContaining({
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ skippedForLaunch: true,
+ }),
+ });
+ expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot());
+ expect(refreshTeams).toHaveBeenCalled();
+ });
+
+ it('skipMemberForLaunch refreshes launch data even when skip fails', async () => {
+ const store = createSliceStore();
+ const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined);
+ const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined);
+ const refreshTeams = vi.fn(async () => undefined);
+ store.setState({
+ fetchMemberSpawnStatuses: refreshSpawnStatuses,
+ fetchTeamAgentRuntime: refreshRuntimeSnapshot,
+ fetchTeams: refreshTeams,
+ });
+ hoisted.skipMemberForLaunch.mockRejectedValueOnce(new Error('skip failed'));
+
+ await expect(store.getState().skipMemberForLaunch('my-team', 'alice')).rejects.toThrow(
+ 'skip failed'
+ );
+
+ expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
+ expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
+ expect(refreshTeams).toHaveBeenCalled();
+ });
+
it('clears stale runtime snapshots on delete', async () => {
const store = createSliceStore();
store.setState({
diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts
index dcdd7901..0bd0c2f8 100644
--- a/test/renderer/utils/teamProvisioningPresentation.test.ts
+++ b/test/renderer/utils/teamProvisioningPresentation.test.ts
@@ -293,6 +293,75 @@ describe('buildTeamProvisioningPresentation', () => {
expect(presentation?.compactTone).toBe('warning');
});
+ it('shows skipped teammates as a continued launch instead of still joining', () => {
+ const presentation = buildTeamProvisioningPresentation({
+ progress: {
+ runId: 'run-3d',
+ teamName: 'mixed-team',
+ state: 'ready',
+ startedAt: '2026-04-13T10:00:00.000Z',
+ updatedAt: '2026-04-13T10:00:08.000Z',
+ message: 'Launch completed',
+ messageSeverity: undefined,
+ pid: 4321,
+ configReady: true,
+ cliLogsTail: '',
+ assistantOutput: '',
+ },
+ members: [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'bob',
+ agentType: 'developer',
+ status: 'unknown',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ],
+ memberSpawnStatuses: {
+ bob: {
+ status: 'skipped',
+ launchState: 'skipped_for_launch',
+ updatedAt: '2026-04-13T10:00:07.000Z',
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ agentToolAccepted: false,
+ skippedForLaunch: true,
+ skipReason: 'Skipped by user after launch failure: OpenCode lane failed',
+ },
+ },
+ memberSpawnSnapshot: {
+ expectedMembers: ['bob'],
+ summary: {
+ confirmedCount: 0,
+ pendingCount: 0,
+ failedCount: 0,
+ skippedCount: 1,
+ runtimeAlivePendingCount: 0,
+ },
+ },
+ });
+
+ expect(presentation?.successMessage).toBe('Launch continued - 1/1 teammates skipped');
+ expect(presentation?.panelMessage).toContain('bob skipped for this launch');
+ expect(presentation?.compactTitle).toBe('Launch continued with skipped teammates');
+ expect(presentation?.compactDetail).toBe('bob skipped');
+ expect(presentation?.compactTone).toBe('warning');
+ expect(presentation?.currentStepIndex).toBe(2);
+ expect(presentation?.hasMembersStillJoining).toBe(false);
+ });
+
it('prefers live member spawn statuses over a stale persisted launch summary', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {