5454_WORKSPACE_PROJECT_INDEX_STATE_KEY = "workspace_project_index"
5555
5656
57+ class WorkspaceProjectLookupMiss (ValueError ):
58+ """A project was absent from the workspace index (as opposed to ambiguous).
59+
60+ Misses are retried once against a freshly rebuilt index, because the
61+ session cache may simply predate an out-of-band project creation (#956).
62+ """
63+
64+
5765@dataclass (frozen = True )
5866class WorkspaceProjectEntry :
5967 """A cloud project resolved together with the workspace that owns it."""
@@ -460,6 +468,17 @@ def _canonical_memory_path_for_workspace(
460468 # Outcome: lookups preserve the complete workspace/project canonical permalink.
461469 if not normalized_remainder :
462470 normalized_remainder = project_permalink
471+
472+ # Same index-form rule as _canonical_memory_path_for_active_route (#957):
473+ # without an active workspace permalink context, stored permalinks are
474+ # project-qualified and a workspace-prefixed pattern cannot match.
475+ if "*" in normalized_remainder and current_workspace_permalink_context () is None :
476+ return build_qualified_permalink_reference (
477+ project_permalink ,
478+ normalized_remainder ,
479+ include_project = True ,
480+ )
481+
463482 return build_qualified_permalink_reference (
464483 project_permalink ,
465484 normalized_remainder ,
@@ -477,6 +496,24 @@ def _canonical_memory_path_for_active_route(
477496) -> str :
478497 """Return the canonical permalink path for the currently routed project/workspace."""
479498 project_prefix = active_project .permalink
499+
500+ # Trigger: the path contains a glob wildcard (folder/*) and no server-side
501+ # workspace permalink context is active.
502+ # Why: patterns match raw against the search index, so they must mirror the
503+ # stored permalink form. The contextvar is what qualified permalinks at
504+ # write time — when it is absent, stored rows are project-qualified and a
505+ # workspace prefix (from the client's cached_workspace display state)
506+ # guarantees zero matches (#957). Direct lookups keep full qualification
507+ # because the link resolver understands it; patterns have no fallback.
508+ # Outcome: without the contextvar, qualify patterns with the project prefix
509+ # only; with it, fall through to normal workspace canonicalization.
510+ if "*" in path and current_workspace_permalink_context () is None :
511+ if not include_project :
512+ return path
513+ if path == project_prefix or path .startswith (f"{ project_prefix } /" ):
514+ return path
515+ return f"{ project_prefix } /{ path } "
516+
480517 workspace_remainder = path
481518 if include_project and (path == project_prefix or path .startswith (f"{ project_prefix } /" )):
482519 # Trigger: the memory URL already names the active project root/prefix
@@ -722,9 +759,15 @@ async def _fetch_workspace_project_entries(
722759
723760async def _ensure_workspace_project_index (
724761 context : Optional [Context ] = None ,
762+ * ,
763+ force_refresh : bool = False ,
725764) -> WorkspaceProjectIndex :
726- """Build or load the session-local workspace/project lookup index."""
727- if context :
765+ """Build or load the session-local workspace/project lookup index.
766+
767+ force_refresh bypasses the cached index and rebuilds from discovery —
768+ used by resolve_workspace_project_identifier when a lookup misses (#956).
769+ """
770+ if context and not force_refresh :
728771 cached_raw = await context .get_state (_WORKSPACE_PROJECT_INDEX_STATE_KEY )
729772 cached_index = _workspace_project_index_from_state (cached_raw )
730773 if cached_index is not None :
@@ -865,7 +908,32 @@ async def resolve_workspace_project_identifier(
865908) -> WorkspaceProjectEntry :
866909 """Resolve a project by external_id (UUID), qualified name, or unqualified name."""
867910 index = await _ensure_workspace_project_index (context = context )
911+ try :
912+ return await _resolve_workspace_project_from_index (index , project , context )
913+ except WorkspaceProjectLookupMiss :
914+ # Trigger: the lookup missed the session-cached index.
915+ # Why: a miss is exactly the signal the cache may be stale — projects
916+ # created out-of-band (CLI, a teammate in a shared workspace) post-date
917+ # the index built at session start (#956).
918+ # Outcome: rebuild the index once and retry; a second miss is authoritative
919+ # and its error (with the refreshed project list) propagates.
920+ logger .info (
921+ f"Workspace project lookup missed for '{ project } '; refreshing index and retrying"
922+ )
923+ refreshed = await _ensure_workspace_project_index (context = context , force_refresh = True )
924+ return await _resolve_workspace_project_from_index (refreshed , project , context )
925+
926+
927+ async def _resolve_workspace_project_from_index (
928+ index : WorkspaceProjectIndex ,
929+ project : str ,
930+ context : Optional [Context ] = None ,
931+ ) -> WorkspaceProjectEntry :
932+ """Resolve a project against one concrete index snapshot.
868933
934+ Raises WorkspaceProjectLookupMiss for absent projects (retryable via index
935+ refresh) and plain ValueError for ambiguity, which a refresh cannot fix.
936+ """
869937 # Fast path: direct lookup by external_id when the identifier is a UUID
870938 # Canonicalize via str(UUID(...)) so uppercase, brace-wrapped, or urn:uuid forms
871939 # all hash to the same lowercase-hyphenated key as the stored external_ids.
@@ -895,7 +963,7 @@ async def resolve_workspace_project_identifier(
895963 failed_workspace .tenant_id == workspace .tenant_id
896964 for failed_workspace in index .failed_workspaces
897965 ):
898- raise ValueError (
966+ raise WorkspaceProjectLookupMiss (
899967 f"Projects for workspace '{ workspace .name } ' ({ workspace .slug } ) "
900968 "could not be loaded. Retry after workspace discovery recovers."
901969 )
@@ -904,7 +972,7 @@ async def resolve_workspace_project_identifier(
904972 for entry in index .entries
905973 if entry .workspace .tenant_id == workspace .tenant_id
906974 )
907- raise ValueError (
975+ raise WorkspaceProjectLookupMiss (
908976 f"Project '{ project_identifier } ' was not found in workspace "
909977 f"'{ workspace .name } ' ({ workspace .slug } ). Available projects: { available } "
910978 )
@@ -929,7 +997,7 @@ async def resolve_workspace_project_identifier(
929997 "retry or use a qualified project from an indexed workspace."
930998 )
931999 available = ", " .join (entry .qualified_name for entry in index .entries )
932- raise ValueError (
1000+ raise WorkspaceProjectLookupMiss (
9331001 f"Project '{ project } ' was not found in indexed cloud workspaces. "
9341002 f"Available projects: { available } .{ failed_note } "
9351003 )
0 commit comments