@@ -16,6 +16,7 @@ import {
1616 buildWorkspaceSeatSummary ,
1717} from '@open-design/contracts' ;
1818import { coalescedGet , forceCoalescedGet } from '../lib/coalesced-get' ;
19+ import { BackoffController , type BackoffOptions } from '../lib/backoff' ;
1920import {
2021 markProjectDisplaySnapshotsDirty ,
2122 patchProjectDisplaySnapshots ,
@@ -231,6 +232,18 @@ function workspaceContextCoalesceKey(): string {
231232 return `workspace-context:${ workspaceContextRequestToken } ` ;
232233}
233234
235+ /**
236+ * The LIVE identity generation token. A cached workspace context is stamped
237+ * with the token it was resolved under (see `resourceReadIdentity.generation`);
238+ * a write path compares that stamp against this live value to decide whether a
239+ * retained (last-good) context still belongs to the current identity, rather
240+ * than trusting a possibly-stale `identityChangePending` snapshot. See
241+ * `resolvedWorkspaceContextForWrite` in `state/projects.ts`.
242+ */
243+ export function currentWorkspaceContextRequestToken ( ) : string {
244+ return workspaceContextRequestToken ;
245+ }
246+
234247async function fetchWorkspaceDirectory ( ) : Promise < WorkspaceDirectoryResponse > {
235248 const response = await fetch ( '/api/workspace/directory' , { cache : 'no-store' } ) ;
236249 if ( ! response . ok ) {
@@ -476,6 +489,7 @@ export function resetWorkspaceContextCache(): void {
476489 workspaceContextIdentityChangePending = false ;
477490 workspaceAccountGeneration = 0 ;
478491 workspaceAccountGenerationStamp = 'initial' ;
492+ resetWorkspaceContextRetrySchedules ( ) ;
479493 writeWorkspaceSelection ( null ) ;
480494}
481495
@@ -701,6 +715,9 @@ export function useWorkspaceContext(): WorkspaceContextState {
701715 cachedWorkspaceContext = nextContext ;
702716 cachedWorkspaceContextGeneration = requestGeneration ;
703717 workspaceContextIdentityChangePending = false ;
718+ // A successful read is the only thing that rewinds the failure-retry
719+ // backoff for this generation.
720+ clearWorkspaceContextRetryFailures ( requestGeneration ) ;
704721 setState ( {
705722 context : cachedWorkspaceContext ,
706723 resourceReadIdentity : cachedWorkspaceContext
@@ -719,6 +736,7 @@ export function useWorkspaceContext(): WorkspaceContextState {
719736 // last-known context instead of flashing the signed-out state. A never-
720737 // signed-in / personal user has a null cache, so this still shows the local
721738 // state for them.
739+ const unsupported = ( error as { status ?: unknown } ) ?. status === 404 ;
722740 setState ( {
723741 context : cachedWorkspaceContext ,
724742 resourceReadIdentity :
@@ -730,11 +748,13 @@ export function useWorkspaceContext(): WorkspaceContextState {
730748 : null ,
731749 loading : false ,
732750 identityChangePending : workspaceContextIdentityChangePending ,
733- failure :
734- ( error as { status ?: unknown } ) ?. status === 404
735- ? 'unsupported'
736- : 'unavailable' ,
751+ failure : unsupported ? 'unsupported' : 'unavailable' ,
737752 } ) ;
753+ // An `unsupported` daemon has no workspace endpoint — retrying is
754+ // pointless. A transient `unavailable` outage arms the shared jittered
755+ // backoff so the shell recovers on its own without waiting for the 30s
756+ // poll or a focus event.
757+ if ( ! unsupported ) scheduleWorkspaceContextRetry ( requestGeneration ) ;
738758 }
739759 } , [ ] ) ;
740760
@@ -815,15 +835,26 @@ export function useWorkspaceContext(): WorkspaceContextState {
815835 advanceWorkspaceAccountGeneration ( event . newValue ?? 'storage' ) ;
816836 refreshAfterIdentityChange ( ) ;
817837 } ;
838+ // A scheduled failure-retry (see `scheduleWorkspaceContextRetry`) fires this
839+ // event for a specific identity generation. Re-read only when it still names
840+ // the current generation — a retry armed for an identity the user has since
841+ // left must not spend a request.
842+ const onContextRetry = ( event : Event ) => {
843+ const detail = ( event as CustomEvent < { requestKey ?: string } > ) . detail ;
844+ if ( detail ?. requestKey !== workspaceContextRequestToken ) return ;
845+ void loadContext ( ) ;
846+ } ;
818847 window . addEventListener ( 'focus' , refresh ) ;
819848 window . addEventListener ( 'pageshow' , refresh ) ;
820849 window . addEventListener ( WORKSPACE_CONTEXT_REFRESH_EVENT , refreshAfterIdentityChange ) ;
850+ window . addEventListener ( WORKSPACE_CONTEXT_RETRY_EVENT , onContextRetry ) ;
821851 window . addEventListener ( 'storage' , onStorage ) ;
822852 document . addEventListener ( 'visibilitychange' , onVisibilityChange ) ;
823853 return ( ) => {
824854 window . removeEventListener ( 'focus' , refresh ) ;
825855 window . removeEventListener ( 'pageshow' , refresh ) ;
826856 window . removeEventListener ( WORKSPACE_CONTEXT_REFRESH_EVENT , refreshAfterIdentityChange ) ;
857+ window . removeEventListener ( WORKSPACE_CONTEXT_RETRY_EVENT , onContextRetry ) ;
827858 window . removeEventListener ( 'storage' , onStorage ) ;
828859 document . removeEventListener ( 'visibilitychange' , onVisibilityChange ) ;
829860 } ;
@@ -1607,7 +1638,7 @@ const WORKSPACE_BILLING_RETRY_EVENT = 'od:workspace-billing-retry';
16071638 * within the share window costs zero network requests).
16081639 */
16091640type WorkspaceBillingRetrySchedule = {
1610- consecutiveFailures : number ;
1641+ backoff : BackoffController ;
16111642 timer : ReturnType < typeof setTimeout > | null ;
16121643} ;
16131644const workspaceBillingRetrySchedules = new Map < string , WorkspaceBillingRetrySchedule > ( ) ;
@@ -1616,15 +1647,23 @@ function scheduleWorkspaceBillingRetry(requestKey: string): void {
16161647 if ( typeof window === 'undefined' ) return ;
16171648 let schedule = workspaceBillingRetrySchedules . get ( requestKey ) ;
16181649 if ( ! schedule ) {
1619- schedule = { consecutiveFailures : 0 , timer : null } ;
1650+ schedule = {
1651+ backoff : new BackoffController ( {
1652+ initialMs : WORKSPACE_BILLING_RETRY_BASE_MS ,
1653+ maxMs : WORKSPACE_BILLING_RETRY_MAX_MS ,
1654+ factor : 2 ,
1655+ // Jitter stays OFF for billing: its exponential schedule predates this
1656+ // change and is pinned by an exact-cadence regression test (the od://
1657+ // 502-storm). Only the arithmetic moves onto the shared controller;
1658+ // the observable 5s→10s→20s→40s→60s timing is unchanged.
1659+ jitter : false ,
1660+ } ) ,
1661+ timer : null ,
1662+ } ;
16201663 workspaceBillingRetrySchedules . set ( requestKey , schedule ) ;
16211664 }
16221665 if ( schedule . timer != null ) return ;
1623- const delay = Math . min (
1624- WORKSPACE_BILLING_RETRY_BASE_MS * 2 ** schedule . consecutiveFailures ,
1625- WORKSPACE_BILLING_RETRY_MAX_MS ,
1626- ) ;
1627- schedule . consecutiveFailures += 1 ;
1666+ const delay = schedule . backoff . nextDelay ( ) ;
16281667 schedule . timer = setTimeout ( ( ) => {
16291668 schedule . timer = null ;
16301669 // Dispatch unconditionally: listeners filter on their own active
@@ -1636,8 +1675,7 @@ function scheduleWorkspaceBillingRetry(requestKey: string): void {
16361675}
16371676
16381677function clearWorkspaceBillingRetryFailures ( requestKey : string ) : void {
1639- const schedule = workspaceBillingRetrySchedules . get ( requestKey ) ;
1640- if ( schedule ) schedule . consecutiveFailures = 0 ;
1678+ workspaceBillingRetrySchedules . get ( requestKey ) ?. backoff . reset ( ) ;
16411679}
16421680
16431681function resetWorkspaceBillingRetrySchedules ( ) : void {
@@ -1646,6 +1684,82 @@ function resetWorkspaceBillingRetrySchedules(): void {
16461684 }
16471685 workspaceBillingRetrySchedules . clear ( ) ;
16481686}
1687+
1688+ // ---------- workspace context failure retry ----------
1689+ //
1690+ // `GET /api/workspace/context` (and its directory prerequisite) previously had
1691+ // NO failure retry: a read that failed just sat on the last-good context until
1692+ // the next 30s poll or a focus event. During a multi-hour vela authority outage
1693+ // that left the shell stale far longer than necessary and, combined with the
1694+ // old fail-closed write gate, drove the create-retry storm this PR fixes.
1695+ //
1696+ // The failure path now arms a jittered exponential-backoff retry (1s → 30s),
1697+ // module-level and keyed by identity generation — one timer shared by every
1698+ // mounted consumer, exactly like the billing schedule above (a per-hook timer
1699+ // would arm a dozen for the dozen-plus mounted `useWorkspaceContext`s). Success
1700+ // resets the depth; an ambient trigger (SSE `workspace-context-changed`, focus,
1701+ // the poll floor) fetches immediately WITHOUT rewinding the depth, so unrelated
1702+ // foreground activity cannot keep kicking a flaky transport back to a 1s cadence.
1703+ const WORKSPACE_CONTEXT_RETRY_BASE_MS = 1_000 ;
1704+ const WORKSPACE_CONTEXT_RETRY_MAX_MS = 30_000 ;
1705+ const WORKSPACE_CONTEXT_RETRY_EVENT = 'od:workspace-context-retry' ;
1706+
1707+ function defaultWorkspaceContextRetryBackoff ( ) : BackoffOptions {
1708+ return {
1709+ initialMs : WORKSPACE_CONTEXT_RETRY_BASE_MS ,
1710+ maxMs : WORKSPACE_CONTEXT_RETRY_MAX_MS ,
1711+ factor : 2 ,
1712+ jitter : true ,
1713+ } ;
1714+ }
1715+
1716+ // Test seam: production uses jittered backoff; a test pins the schedule to a
1717+ // deterministic sequence (jitter off) to assert the exact 1s→2s→4s…→30s growth.
1718+ let workspaceContextRetryBackoffOptions : BackoffOptions = defaultWorkspaceContextRetryBackoff ( ) ;
1719+
1720+ export function __setWorkspaceContextRetryBackoffForTests (
1721+ options : BackoffOptions | null ,
1722+ ) : void {
1723+ workspaceContextRetryBackoffOptions = options ?? defaultWorkspaceContextRetryBackoff ( ) ;
1724+ }
1725+
1726+ type WorkspaceContextRetrySchedule = {
1727+ backoff : BackoffController ;
1728+ timer : ReturnType < typeof setTimeout > | null ;
1729+ } ;
1730+ const workspaceContextRetrySchedules = new Map < string , WorkspaceContextRetrySchedule > ( ) ;
1731+
1732+ function scheduleWorkspaceContextRetry ( requestKey : string ) : void {
1733+ if ( typeof window === 'undefined' ) return ;
1734+ let schedule = workspaceContextRetrySchedules . get ( requestKey ) ;
1735+ if ( ! schedule ) {
1736+ schedule = {
1737+ backoff : new BackoffController ( workspaceContextRetryBackoffOptions ) ,
1738+ timer : null ,
1739+ } ;
1740+ workspaceContextRetrySchedules . set ( requestKey , schedule ) ;
1741+ }
1742+ if ( schedule . timer != null ) return ;
1743+ const delay = schedule . backoff . nextDelay ( ) ;
1744+ schedule . timer = setTimeout ( ( ) => {
1745+ schedule . timer = null ;
1746+ window . dispatchEvent (
1747+ new CustomEvent ( WORKSPACE_CONTEXT_RETRY_EVENT , { detail : { requestKey } } ) ,
1748+ ) ;
1749+ } , delay ) ;
1750+ }
1751+
1752+ function clearWorkspaceContextRetryFailures ( requestKey : string ) : void {
1753+ workspaceContextRetrySchedules . get ( requestKey ) ?. backoff . reset ( ) ;
1754+ }
1755+
1756+ function resetWorkspaceContextRetrySchedules ( ) : void {
1757+ for ( const schedule of workspaceContextRetrySchedules . values ( ) ) {
1758+ if ( schedule . timer != null ) clearTimeout ( schedule . timer ) ;
1759+ }
1760+ workspaceContextRetrySchedules . clear ( ) ;
1761+ }
1762+
16491763export const WORKSPACE_BILLING_REFRESH_EVENT = 'od:workspace-billing-refresh' ;
16501764const WORKSPACE_BILLING_REFRESH_STORAGE_KEY = 'od.workspaceBilling.refreshAt' ;
16511765
0 commit comments