From 3645769b3a4ccd32cacd47a22fddb2ca94cf523c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:00:32 -0700 Subject: [PATCH] fix(web): woke acknowledgement syncs, preview mute returns, and retries back off - Woke acknowledgement (sidebar pill and chat banner) records a server visit at the wake time, so it clears on every device. - Restore previewRuntimeTabId on both RightPanelTabs mounts so the preview tab mute button and menu item work again. - Remove the root PlanAgentSelectionHeal: a per-device setting must not rewrite shared server model selections. Shared pickers keep and show a stored plan agent. - Shell and thread subscriptions back off up to 30s on repeated expected failures and stop retrying authorization failures. - Settings > General gets a Project order control for the new sidebar, included in Restore defaults. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 29 ++++- apps/web/src/components/Sidebar.tsx | 10 +- .../components/settings/SettingsPanels.tsx | 58 +++++++++- .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/hooks/useThreadActions.ts | 24 ++++ apps/web/src/modelSelection.test.ts | 109 ++++++------------ apps/web/src/modelSelection.ts | 50 ++------ apps/web/src/planAgentSelectionHeal.tsx | 52 --------- apps/web/src/routes/__root.tsx | 2 - .../client-runtime/src/rpc/client.test.ts | 109 ++++++++++++++++++ packages/client-runtime/src/rpc/client.ts | 50 ++++++-- 11 files changed, 306 insertions(+), 193 deletions(-) delete mode 100644 apps/web/src/planAgentSelectionHeal.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e2037c2a0645..c6a580d1e157 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,7 +85,7 @@ import { import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { useThreadActions } from "../hooks/useThreadActions"; +import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, formatModelSelectionEffort, @@ -250,6 +250,7 @@ import { useThreadPreviewState, } from "../previewStateStore"; import { BrowserSettingsReadError } from "../browser/openFileInPreview"; +import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -2230,6 +2231,14 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewServerEpoch = activePreviewState.serverEpoch; + const resolvePreviewRuntimeTabId = useMemo( + () => + activeThreadRef + ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) + : undefined, + [activeThreadRef, activePreviewServerEpoch], + ); const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); @@ -6626,14 +6635,20 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell !== null && supportsSnooze ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() }) : null; + const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); const acknowledgeActiveThreadWoke = useCallback(() => { if (activeThreadRef === null || activeThreadWokeAt === null) return; - markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); - }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread. - const activeThreadLastVisitedAt = useUiStateStore((store) => + acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); + }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]); + // Mirror of the sidebar's Woke pill for the open thread. Same watermark as + // the sidebar, so an acknowledgement from any device hides it. + const activeThreadLocalVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); + const activeThreadLastVisitedAt = resolveThreadLastVisitedAt( + activeThreadShell?.lastVisitedAt, + activeThreadLocalVisitedAt, + ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; if (activeThreadShell?.settledOverride === "settled") return false; @@ -6643,7 +6658,7 @@ export default function ChatView(props: ChatViewProps) { // above stamps it); folding that floor in here keeps a completion- // triggered wake from flashing a banner for one frame before the stamp // lands. An unparseable stored visit counts as never-visited: corrupt - // local data must not eat the wake signal. + // data must not eat the wake signal. const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; const completedAtMs = activeLatestRun?.completedAt ? Date.parse(activeLatestRun.completedAt) @@ -11159,6 +11174,7 @@ export default function ChatView(props: ChatViewProps) { pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} + previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} @@ -11213,6 +11229,7 @@ export default function ChatView(props: ChatViewProps) { pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} + previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9280947c5e47..eb0499b0d93f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -130,7 +130,7 @@ import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; -import { useThreadActions } from "../hooks/useThreadActions"; +import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -2381,13 +2381,7 @@ export default function Sidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); - const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); - const acknowledgeWoke = useCallback( - (threadRef: ScopedThreadRef, visitedAt: string) => { - markThreadVisited(scopedThreadKey(threadRef), visitedAt); - }, - [markThreadVisited], - ); + const acknowledgeWoke = useAcknowledgeThreadWoke(); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a11bcf730b86..2d3442bdbcee 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -44,6 +44,7 @@ import { type ResponseStreamingMode, MIN_TERMINAL_FONT_SIZE, type QuitConfirmationMode, + SidebarProjectSortOrder, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, + selectsPlanAgent, } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -181,6 +183,13 @@ const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record = { + updated_at: "Last user message", + created_at: "Created at", + manual: "Manual", +}; +const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder); + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -552,6 +561,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder + ? ["Project order"] + : []), ...(settings.sidebarWorkingShelfEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? ["Working section"] @@ -690,6 +702,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, settings.sidebarProjectGroupingMode, + settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -789,6 +802,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, @@ -2270,6 +2284,46 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, + }) + } + /> + ) : null + } + control={ + + } + /> {}} modelOptions={textGenModelOptions} allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} + planModeEnabled={ + settings.planModeEnabled || selectsPlanAgent(textGenModelOptions) + } triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} onModelOptionsChange={(nextOptions) => { updateSettings({ diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7e58a92e679f..ae55a327d163 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -280,6 +280,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "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/settings/general", searchTerms: ["combine matching repositories environments sidebar"], }, + { + id: "project-order", + title: "Project order", + to: "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/settings/general", + searchTerms: ["sort projects sidebar manual created recent"], + }, { id: "snooze-limited-threads", title: "Snooze limited threads", diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7ebfe47e6d68..4328d61da82f 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -219,6 +219,30 @@ function useMarkThreadUnread() { ); } +/** + * Clears a thread's Woke marker by recording a visit at the wake time. + * Servers with visited tracking own the watermark (thread.visit keeps the + * later of the stored and supplied values, so this syncs to every device); + * older servers keep the browser-local watermark. + */ +export function useAcknowledgeThreadWoke() { + const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + const markThreadVisited = useUiStateStore((state) => state.markThreadVisited); + return useCallback( + (target: ScopedThreadRef, wokeAt: string) => { + if (readEnvironmentSupportsVisitedTracking(target.environmentId)) { + void visitThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, visitedAt: wokeAt }, + }); + return; + } + markThreadVisited(scopedThreadKey(target), wokeAt); + }, + [markThreadVisited, visitThreadMutation], + ); +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index cc4a65f2162c..bcd9e59519bc 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -15,8 +15,6 @@ import { getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, - resolvePlanAgentHealPatch, - withoutPlanAgentSelection, } from "./modelSelection"; function provider(input: { @@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => { }); }); -describe("withoutPlanAgentSelection", () => { - const instance = ProviderInstanceId.make("opencode"); +describe("resolveAppModelSelectionState with the opencode plan agent", () => { + const instanceId = ProviderInstanceId.make("opencode"); const model = "opencode/gpt-5.4"; - - it("drops a stored plan agent option", () => { - const selection = createModelSelection(instance, model, [ - { id: "variant", value: "high" }, + const opencode: ServerProvider = { + ...provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }), + models: [ + { + slug: model, + name: model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ], + }, + }, + ], + }; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + planModeEnabled: false, + textGenerationModelSelection: createModelSelection(instanceId, model, [ { id: "agent", value: "plan" }, - ]); - expect(withoutPlanAgentSelection(selection)).toEqual( - createModelSelection(instance, model, [{ id: "variant", value: "high" }]), - ); - }); - - it("keeps non-plan agent options", () => { - const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]); - expect(withoutPlanAgentSelection(selection)).toBe(selection); - }); - - it("omits options entirely when plan was the only stored option", () => { - const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]); - expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model }); - }); - - it("returns null and undefined selections unchanged", () => { - expect(withoutPlanAgentSelection(null)).toBeNull(); - expect(withoutPlanAgentSelection(undefined)).toBeUndefined(); - }); -}); - -describe("resolvePlanAgentHealPatch", () => { - const instance = ProviderInstanceId.make("opencode"); - const model = "opencode/gpt-5.4"; - const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]); - const storedPlan = createModelSelection(instance, model, [ - { id: "variant", value: "high" }, - { id: "agent", value: "plan" }, - ]); - const nullPatch = { - planModeEnabled: true, - textGenerationModelSelection: storedPlan, - sourceControlWriterModelSelection: null, + ]), }; - it("returns null when plan mode is on", () => { - expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull(); - }); - - it("returns null when nothing needs healing", () => { - expect( - resolvePlanAgentHealPatch({ - planModeEnabled: false, - textGenerationModelSelection: healed, - sourceControlWriterModelSelection: null, - }), - ).toBeNull(); - }); - - it("patches the stored text generation selection to drop the plan agent", () => { - expect( - resolvePlanAgentHealPatch({ - planModeEnabled: false, - textGenerationModelSelection: storedPlan, - sourceControlWriterModelSelection: null, - }), - ).toEqual({ textGenerationModelSelection: healed }); - }); - - it("patches a stored source control writer selection that uses the plan agent", () => { - expect( - resolvePlanAgentHealPatch({ - planModeEnabled: false, - textGenerationModelSelection: healed, - sourceControlWriterModelSelection: storedPlan, - }), - ).toEqual({ sourceControlWriterModelSelection: healed }); + it("keeps a stored plan agent this device did not pick", () => { + expect(resolveAppModelSelectionState(settings, [opencode])).toEqual( + createModelSelection(instanceId, model, [{ id: "agent", value: "plan" }]), + ); }); }); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 1a39f06098ac..91e7251f35fe 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -7,7 +7,6 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider, - type ServerSettingsPatch, } from "@t3tools/contracts"; import { type CustomModelDefinition, @@ -350,48 +349,13 @@ export function getCustomModelOptionsByInstance( } /** - * Drop the opencode "plan" agent option from a stored model selection. - * Used when legacy plan mode is turned off so server-side text-generation - * tasks (title, branch, PR) cannot keep dispatching the plan agent. + * Whether stored model options pick the opencode "plan" agent. Shared settings + * pickers keep and show such a value even while this device's legacy plan + * mode is off: another device may have chosen it, and this device's filter + * only applies to picks made here. */ -export function withoutPlanAgentSelection( - selection: ModelSelection | null | undefined, -): ModelSelection | null | undefined { - if (!selection?.options) { - return selection; - } - const options = selection.options.filter( - (option) => !(option.id === "agent" && option.value === "plan"), - ); - if (options.length === selection.options.length) { - return selection; - } - return createModelSelection(selection.instanceId, selection.model, options); -} - -// The dropdown hides the opencode "plan" agent while legacy plan mode is off, -// but the persisted text-generation selections are only healed when the toggle -// flips. Users who already have plan mode off and a stored "plan" selection -// never trip the toggle handler, so resolve the heal once per settings load. -export function resolvePlanAgentHealPatch(input: { - readonly planModeEnabled: boolean; - readonly textGenerationModelSelection: ModelSelection | null | undefined; - readonly sourceControlWriterModelSelection: ModelSelection | null | undefined; -}): ServerSettingsPatch | null { - if (input.planModeEnabled) { - return null; - } - const healedText = withoutPlanAgentSelection(input.textGenerationModelSelection); - const healedSourceControl = withoutPlanAgentSelection(input.sourceControlWriterModelSelection); - const patch: ServerSettingsPatch = { - ...(healedText && healedText !== input.textGenerationModelSelection - ? { textGenerationModelSelection: healedText } - : {}), - ...(healedSourceControl && healedSourceControl !== input.sourceControlWriterModelSelection - ? { sourceControlWriterModelSelection: healedSourceControl } - : {}), - }; - return Object.keys(patch).length > 0 ? patch : null; +export function selectsPlanAgent(options: ModelSelection["options"]): boolean { + return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false; } export function resolveAppModelSelectionState( @@ -433,7 +397,7 @@ export function resolveAppModelSelectionState( model, models: entry.models, modelOptions: selectedEntry ? selection.options : undefined, - planModeEnabled: settings.planModeEnabled, + planModeEnabled: settings.planModeEnabled || selectsPlanAgent(selection.options), }); return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); diff --git a/apps/web/src/planAgentSelectionHeal.tsx b/apps/web/src/planAgentSelectionHeal.tsx deleted file mode 100644 index 4f6760e50fae..000000000000 --- a/apps/web/src/planAgentSelectionHeal.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect } from "react"; - -import { - useClientSettingsHydrated, - usePrimarySettings, - useUpdatePrimarySettings, -} from "./hooks/useSettings"; -import { resolvePlanAgentHealPatch } from "./modelSelection"; - -/** - * Heals persisted text-generation model selections that still reference the - * opencode "plan" agent. The dropdown hides the option while legacy plan mode - * is off, but the toggle handler only runs when the setting flips; users who - * already have plan mode off with a stored "plan" selection need this pass - * whenever the settings load. - */ -export function PlanAgentSelectionHeal() { - const planModeEnabled = usePrimarySettings((settings) => settings.planModeEnabled); - const textGenerationModelSelection = usePrimarySettings( - (settings) => settings.textGenerationModelSelection, - ); - const sourceControlWriterModelSelection = usePrimarySettings( - (settings) => settings.sourceControlWriterModelSelection, - ); - const settingsHydrated = useClientSettingsHydrated(); - const updateSettings = useUpdatePrimarySettings(); - - useEffect(() => { - // planModeEnabled reads as false until client settings hydrate, so never - // heal before then: we would strip a stored plan selection from a user - // whose plan mode is actually on. - if (!settingsHydrated) { - return; - } - const patch = resolvePlanAgentHealPatch({ - planModeEnabled, - textGenerationModelSelection, - sourceControlWriterModelSelection, - }); - if (patch) { - updateSettings(patch); - } - }, [ - planModeEnabled, - settingsHydrated, - textGenerationModelSelection, - sourceControlWriterModelSelection, - updateSettings, - ]); - - return null; -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 6cc11846c570..b5cefd1342ea 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -51,7 +51,6 @@ import { isElectron } from "../env"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; import { useClientSettings } from "../hooks/useSettings"; -import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -242,7 +241,6 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? ( ) : null} - {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 032603f28a96..8dda4440e673 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentAuthorizationError, EnvironmentId, PreviewTabId, ThreadId, @@ -618,6 +619,114 @@ describe("environment RPC", () => { }), ); + it.effect("doubles the retry delay for repeated failures and resets it after a value", () => + Effect.gen(function* () { + const domainError = new Error("thread not hydrated yet"); + const subscriptions = yield* Queue.unbounded(); + const failures = yield* Queue.unbounded(); + let attempts = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Effect.sync(() => { + attempts += 1; + return attempts; + }).pipe( + Effect.tap((attempt) => Queue.offer(subscriptions, attempt)), + Effect.map((attempt) => { + if (attempt <= 2) return Stream.fail(domainError); + if (attempt === 3) + return Stream.concat(Stream.make("event"), Stream.fail(domainError)); + return Stream.never; + }), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Queue.offer(failures, undefined), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + expect(yield* Queue.take(subscriptions)).toBe(1); + yield* Queue.take(failures); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(2); + yield* Queue.take(failures); + // The second retry waits 200ms, so 100ms is not enough. + yield* TestClock.adjust("100 millis"); + expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(3); + // Attempt 3 delivered a value before failing, so the delay is back to 100ms. + yield* Queue.take(failures); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(4); + yield* Fiber.interrupt(subscriptionFiber); + }), + ); + + it.effect("waits for the next session after an authorization failure", () => + Effect.gen(function* () { + const subscriptions = yield* Queue.unbounded(); + const failed = yield* Deferred.make(); + let attempts = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Queue.offer(subscriptions, undefined).pipe( + Effect.map(() => { + attempts += 1; + return attempts === 1 + ? Stream.fail( + new EnvironmentAuthorizationError({ + message: "Missing scope", + requiredScope: "orchestration:read", + }), + ) + : Stream.never; + }), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Deferred.succeed(failed, undefined).pipe(Effect.asVoid), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + yield* Queue.take(subscriptions); + yield* Deferred.await(failed); + yield* TestClock.adjust("1 minute"); + expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + yield* Queue.take(subscriptions); + yield* Fiber.interrupt(subscriptionFiber); + expect(attempts).toBe(2); + }), + ); + it.effect.each(["input", "stream"] as const)( "does not classify %s subscription defects as expected failures", (where) => diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 426ea63482ea..4ce45cf3ff1b 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,7 +1,11 @@ -import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +import { + EnvironmentAuthorizationError, + ORCHESTRATION_V2_WS_METHODS, + WS_METHODS, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import type * as Duration from "effect/Duration"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -92,6 +96,10 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ }) {} export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); + +/** Ceiling for the doubling delay between same-session expected-failure retries. */ +const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; export type EnvironmentRpcInput = Parameters>[0]; @@ -192,6 +200,12 @@ interface SubscriptionOptions { readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; + /** + * First delay before resubscribing on the same session after an expected + * failure. Each consecutive failure doubles it up to 30 seconds, and the + * first value from a healthy stream resets it. Authorization failures are + * not retried; they wait for the next session or `resubscribe` signal. + */ readonly retryExpectedFailureAfter?: Duration.Input; readonly resubscribe?: Stream.Stream; } @@ -238,6 +252,9 @@ function subscribeDynamicMapped( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; + // Consecutive expected-failure retries on this session. Reset by + // the first value a resubscribed stream delivers. + let expectedFailureRetries = 0; const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( @@ -248,7 +265,13 @@ function subscribeDynamicMapped( method: tag, input, }); - const stream = mapStream(session, method(input)); + const stream = mapStream(session, method(input)).pipe( + Stream.onFirst(() => + Effect.sync(() => { + expectedFailureRetries = 0; + }), + ), + ); // An evicted preview host completes its registration stream. // Re-register only after completion; failures still follow the // session recovery policy and browser actions are never replayed. @@ -296,14 +319,27 @@ function subscribeDynamicMapped( const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( Stream.drain, ); - if (options.retryExpectedFailureAfter === undefined) { + const isAuthorizationFailure = cause.reasons.some( + (reason) => + reason._tag === "Fail" && isEnvironmentAuthorizationError(reason.error), + ); + if ( + options.retryExpectedFailureAfter === undefined || + isAuthorizationFailure + ) { return handled; } + const retryDelay = Duration.millis( + Math.min( + Duration.toMillis(options.retryExpectedFailureAfter) * + 2 ** expectedFailureRetries, + MAX_EXPECTED_FAILURE_RETRY_DELAY_MS, + ), + ); + expectedFailureRetries += 1; return handled.pipe( Stream.concat( - Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe( - Stream.drain, - ), + Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain), ), Stream.concat(subscribeToSession()), );