diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index d13d5614..4f58d3cc 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -371,14 +371,26 @@ function mapOpenCodeLaunchDataToRuntimeResult( const members = Object.fromEntries( input.expectedMembers.map((member) => { const bridgeMember = data.members[member.name]; + const fallbackLaunchState = bridgeMember + ? bridgeMember.launchState + : data.teamLaunchState === 'failed' + ? 'failed' + : data.teamLaunchState === 'permission_blocked' + ? 'permission_blocked' + : 'created'; return [ member.name, mapBridgeMemberToRuntimeEvidence( member.name, - bridgeMember?.launchState ?? 'failed', + fallbackLaunchState, bridgeMember?.sessionId, bridgeMember?.runtimePid, [ + ...(bridgeMember + ? [] + : [ + `OpenCode bridge response did not include ${member.name}; keeping the member pending until lane state materializes.`, + ]), ...(bridgeMember?.evidence ?? []).map( (evidence) => `${evidence.kind} at ${evidence.observedAt}` ), diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index 568174e0..31715201 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -106,9 +106,12 @@ export function getLaunchJoinMilestonesFromMembers({ memberSpawnSnapshot?: Pick; }): LaunchJoinMilestones { const teammates = members.filter((member) => !member.removedAt && !isLeadMember(member)); + const activeTeammateNames = new Set(teammates.map((member) => member.name)); const teammateNames = memberSpawnSnapshot?.expectedMembers?.length && memberSpawnSnapshot.expectedMembers.length > 0 - ? memberSpawnSnapshot.expectedMembers + ? memberSpawnSnapshot.expectedMembers.filter((memberName) => + activeTeammateNames.has(memberName) + ) : teammates.map((member) => member.name); const expectedTeammateCount = teammateNames.length; const snapshotSummary = memberSpawnSnapshot?.summary; diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index a6b3ffff..bd696284 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -162,6 +162,62 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); + it('keeps missing bridge members pending while reconcile is still launching', async () => { + const reconcileOpenCodeTeam = vi.fn(async () => ({ + runId: 'run-1', + teamLaunchState: 'launching', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'confirmed_alive', + model: 'openai/gpt-5.4-mini', + evidence: [{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }], + }, + }, + warnings: [], + diagnostics: [], + durableCheckpoints: [], + manifestHighWatermark: null, + runtimeStoreManifestHighWatermark: null, + }) satisfies OpenCodeLaunchTeamCommandData); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + reconcileOpenCodeTeam, + }), + { launchMode: 'dogfood' } + ); + + const result = await adapter.reconcile({ + runId: 'run-1', + teamName: 'team-a', + providerId: 'opencode', + expectedMembers: [ + ...launchInput().expectedMembers, + { + name: 'bob', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + cwd: '/repo', + }, + ], + previousLaunchState: launchSnapshot(), + reason: 'startup_recovery', + }); + + expect(result.teamLaunchState).toBe('partial_pending'); + expect(result.members.alice?.launchState).toBe('confirmed_alive'); + expect(result.members.bob).toMatchObject({ + providerId: 'opencode', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + }); + expect(result.members.bob?.diagnostics).toContain( + 'OpenCode bridge response did not include bob; keeping the member pending until lane state materializes.' + ); + }); + it('acknowledges stop without mutating live OpenCode ownership in the adapter shell', async () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'adapter_disabled', launchAllowed: false })) diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index afedb507..b973844a 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -384,4 +384,76 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.panelMessage).toBeNull(); expect(presentation?.currentStepIndex).toBe(4); }); + + it('ignores removed teammates that still linger in persisted expectedMembers', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-6', + teamName: 'codex-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'alice', + agentType: 'reviewer', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + removedAt: 1_713_000_000_000, + }, + ], + memberSpawnStatuses: { + alice: { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + agentToolAccepted: true, + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['alice', 'bob'], + summary: { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.compactTitle).toBe('Team launched'); + expect(presentation?.compactDetail).toBe('All 1 teammates joined'); + expect(presentation?.panelMessage).toBeNull(); + expect(presentation?.currentStepIndex).toBe(4); + }); });