@@ -57,6 +57,7 @@ class WorkspaceProjectIndex:
5757 workspaces : tuple [WorkspaceInfo , ...]
5858 entries : tuple [WorkspaceProjectEntry , ...]
5959 entries_by_permalink : dict [str , tuple [WorkspaceProjectEntry , ...]]
60+ failed_workspaces : tuple [WorkspaceInfo , ...] = ()
6061
6162
6263def set_workspace_provider (provider : Callable [[], Awaitable [list [WorkspaceInfo ]]]) -> None :
@@ -300,6 +301,12 @@ def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex |
300301 return None
301302
302303 workspaces = tuple (WorkspaceInfo .model_validate (item ) for item in workspaces_raw )
304+ failed_workspaces_raw = raw_mapping .get ("failed_workspaces" )
305+ failed_workspaces = (
306+ tuple (WorkspaceInfo .model_validate (item ) for item in failed_workspaces_raw )
307+ if isinstance (failed_workspaces_raw , list )
308+ else ()
309+ )
303310 entries_list : list [WorkspaceProjectEntry ] = []
304311 for item in entries_raw :
305312 if not isinstance (item , dict ):
@@ -316,13 +323,18 @@ def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex |
316323 )
317324 )
318325 entries = tuple (entries_list )
319- return _build_workspace_project_index (workspaces , entries )
326+ return _build_workspace_project_index (
327+ workspaces ,
328+ entries ,
329+ failed_workspaces = failed_workspaces ,
330+ )
320331
321332
322333def _workspace_project_index_to_state (index : WorkspaceProjectIndex ) -> dict :
323334 """Serialize a workspace project index for MCP context state."""
324335 return {
325336 "workspaces" : [workspace .model_dump () for workspace in index .workspaces ],
337+ "failed_workspaces" : [workspace .model_dump () for workspace in index .failed_workspaces ],
326338 "entries" : [
327339 {
328340 "workspace" : entry .workspace .model_dump (),
@@ -336,6 +348,8 @@ def _workspace_project_index_to_state(index: WorkspaceProjectIndex) -> dict:
336348def _build_workspace_project_index (
337349 workspaces : tuple [WorkspaceInfo , ...],
338350 entries : tuple [WorkspaceProjectEntry , ...],
351+ * ,
352+ failed_workspaces : tuple [WorkspaceInfo , ...] = (),
339353) -> WorkspaceProjectIndex :
340354 """Build the permalink lookup table for workspace-project entries."""
341355 grouped : dict [str , list [WorkspaceProjectEntry ]] = {}
@@ -349,6 +363,7 @@ def _build_workspace_project_index(
349363 permalink : tuple (items )
350364 for permalink , items in sorted (grouped .items (), key = lambda item : item [0 ])
351365 },
366+ failed_workspaces = failed_workspaces ,
352367 )
353368
354369
@@ -469,11 +484,50 @@ async def _ensure_workspace_project_index(
469484 "Ensure you have an active subscription and tenant access."
470485 )
471486
472- fetched_entries = await asyncio .gather (
473- * [_fetch_workspace_project_entries (workspace , context = context ) for workspace in workspaces ]
487+ fetched_results = await asyncio .gather (
488+ * [_fetch_workspace_project_entries (workspace , context = context ) for workspace in workspaces ],
489+ return_exceptions = True ,
490+ )
491+ entries_list : list [WorkspaceProjectEntry ] = []
492+ failed_workspaces : list [WorkspaceInfo ] = []
493+ successful_fetches = 0
494+ for workspace , result in zip (workspaces , fetched_results , strict = True ):
495+ if isinstance (result , BaseException ):
496+ if not isinstance (result , Exception ):
497+ raise result
498+ # Trigger: one workspace project listing failed during a multi-workspace index.
499+ # Why: a transient or unauthorized tenant should not break qualified routing for
500+ # healthy workspaces, but unqualified routing still needs to know the index is partial.
501+ # Outcome: keep successful workspace entries and record the failed workspace.
502+ failed_workspaces .append (workspace )
503+ logger .warning (
504+ f"Cloud project discovery failed for workspace { workspace .slug } "
505+ f"({ workspace .tenant_id } ): { result } "
506+ )
507+ if context : # pragma: no cover
508+ await context .info (
509+ f"Cloud project discovery failed for workspace { workspace .slug } ; "
510+ "continuing with other workspaces"
511+ )
512+ continue
513+
514+ workspace_entries = cast (tuple [WorkspaceProjectEntry , ...], result )
515+ successful_fetches += 1
516+ entries_list .extend (workspace_entries )
517+
518+ if failed_workspaces and successful_fetches == 0 :
519+ failed_labels = ", " .join (workspace .slug for workspace in failed_workspaces )
520+ raise ValueError (
521+ "Unable to discover projects in any accessible workspace. "
522+ f"Failed workspaces: { failed_labels } "
523+ )
524+
525+ entries = tuple (entries_list )
526+ index = _build_workspace_project_index (
527+ workspaces ,
528+ entries ,
529+ failed_workspaces = tuple (failed_workspaces ),
474530 )
475- entries = tuple (entry for workspace_entries in fetched_entries for entry in workspace_entries )
476- index = _build_workspace_project_index (workspaces , entries )
477531
478532 if context :
479533 await context .set_state (
@@ -520,6 +574,14 @@ async def resolve_workspace_project_identifier(
520574 if entry .workspace .tenant_id == workspace .tenant_id
521575 ]
522576 if not matches :
577+ if any (
578+ failed_workspace .tenant_id == workspace .tenant_id
579+ for failed_workspace in index .failed_workspaces
580+ ):
581+ raise ValueError (
582+ f"Projects for workspace '{ workspace .name } ' ({ workspace .slug } ) "
583+ "could not be loaded. Retry after workspace discovery recovers."
584+ )
523585 available = ", " .join (
524586 entry .qualified_name
525587 for entry in index .entries
@@ -533,10 +595,17 @@ async def resolve_workspace_project_identifier(
533595
534596 matches = index .entries_by_permalink .get (project_permalink , ())
535597 if not matches :
598+ failed_note = ""
599+ if index .failed_workspaces :
600+ failed = ", " .join (workspace .slug for workspace in index .failed_workspaces )
601+ failed_note = (
602+ f" Project discovery failed for workspace(s): { failed } ; "
603+ "retry or use a qualified project from an indexed workspace."
604+ )
536605 available = ", " .join (entry .qualified_name for entry in index .entries )
537606 raise ValueError (
538- f"Project '{ project } ' was not found in any accessible cloud workspace . "
539- f"Available projects: { available } "
607+ f"Project '{ project } ' was not found in indexed cloud workspaces . "
608+ f"Available projects: { available } . { failed_note } "
540609 )
541610
542611 cached_workspace = await _get_cached_active_workspace (context )
@@ -557,6 +626,15 @@ async def resolve_workspace_project_identifier(
557626 f"Project '{ project } ' exists in multiple workspaces. Use: { choices } \n { details } "
558627 )
559628
629+ if index .failed_workspaces :
630+ qualified_name = matches [0 ].qualified_name
631+ failed = ", " .join (workspace .slug for workspace in index .failed_workspaces )
632+ raise ValueError (
633+ f"Project '{ project } ' was found as { qualified_name } , but project discovery "
634+ f"failed for workspace(s): { failed } . Use '{ qualified_name } ' to route "
635+ "explicitly, or retry after discovery recovers."
636+ )
637+
560638 return matches [0 ]
561639
562640
0 commit comments