From 40be0d5253b9bd3d301b5f8ff61575485ec60fc3 Mon Sep 17 00:00:00 2001 From: JimmyBlanquet Date: Sat, 30 May 2026 22:44:12 +0200 Subject: [PATCH] fix(office): unblock Hermes adapter + stop persistence loops (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(office): unblock Hermes adapter integration (Phase 2 Claw3D) Two bugs prevent the Hermes adapter from auto-connecting on /office: 1. useSearchParams() at OfficeScreen root suspends during hydration in Next.js dev mode, keeping the parent Suspense fallback stuck on "Loading..." indefinitely. Replace with sync useMemo reading window.location.search so debugEnabled is stable across renders. 2. hasLastKnownGoodState only became true when gateway.lastKnownGood.adapterType matched the currently selected adapter. Switching to Hermes never auto-connected because lastKnownGood was still "openclaw" from the prior OpenClaw session. Allow auto-connect for auto-managed adapters (hermes/openclaw/demo) when a persisted URL exists for the selected adapter, regardless of lastKnownGood. E2E pipeline validated via node WS shell: - ws://127.0.0.1:3030/api/gateway/ws receives connect.challenge - connect frame returns hello-ok with adapterType=hermes + full method list - Upstream chain: proxy(:3030) → adapter(:18790) → Hermes API(:8642) * fix(office,gateway,coordinator): stop persistence loops causing PUT /api/studio spam Three distinct bugs caused the persistence layer to schedule patches on every render after the OfficeScreen mounted, generating PUT /api/studio ~1/second indefinitely and triggering React "Maximum update depth exceeded" warnings: 1. GatewayClient.ts:1102 — schedulePatch effect compared against a stale baseline (`loadedGatewaySettings.current` frozen at boot). After a valid adapter switch (e.g. to Hermes), each render still saw the state as "dirty" and re-scheduled the same patch. Fix: add a snapshot ref + update the baseline after scheduling. 2. useTaskBoardController.ts:833 — taskBoard effect persisted on every `state.cards` reference change without semantic equality check. Fix: add a JSON-snapshot ref guard. 3. useTaskBoardController.ts:1154 — dedupe effect dispatched archive marks for duplicate cards, which mutated `state.cards` and re-ran the effect. Fix: snapshot ref so a structurally-identical card set doesn't re-enter dedupe. 4. coordinator.ts:354 — `mergeStudioPatch` `!current` branch missed copying `taskBoard`, allowing the merged patch to drop persisted task-board state on first write. Validated via Playwright + dev server logs: - Before: ~1 PUT /api/studio per second - After: 0 PUT in a 30s steady-state window post-mount - React "Maximum update depth" warnings reduced from ~3-4/30s to ≤1/30s --- src/features/office/screens/OfficeScreen.tsx | 13 ++++++-- .../office/tasks/useTaskBoardController.ts | 28 +++++++++++++++++ src/lib/gateway/GatewayClient.ts | 31 +++++++++++++++++-- src/lib/studio/coordinator.ts | 1 + 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/features/office/screens/OfficeScreen.tsx b/src/features/office/screens/OfficeScreen.tsx index 8a941cc..21cf77e 100644 --- a/src/features/office/screens/OfficeScreen.tsx +++ b/src/features/office/screens/OfficeScreen.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Mic } from "lucide-react"; import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D"; import type { OfficeAgent } from "@/features/retro-office/core/types"; @@ -953,8 +953,15 @@ type OfficeScreenProps = { export function OfficeScreen({ showOpenClawConsole = true, }: OfficeScreenProps) { - const searchParams = useSearchParams(); - const debugEnabled = searchParams.get("officeDebug") === "1"; + // Patch Hermes Phase 2: avoid useSearchParams() at component root — it + // suspends during hydration in Next.js dev mode and keeps the parent + // Suspense fallback stuck on "Loading...". Use a sync useMemo so + // debugEnabled is stable across renders (state+useEffect caused a + // re-render cascade through downstream useCallbacks). + const debugEnabled = useMemo(() => { + if (typeof window === "undefined") return false; + return new URLSearchParams(window.location.search).get("officeDebug") === "1"; + }, []); const [settingsCoordinator] = useState(() => createStudioSettingsCoordinator(), ); diff --git a/src/features/office/tasks/useTaskBoardController.ts b/src/features/office/tasks/useTaskBoardController.ts index 93fe18e..fddfd98 100644 --- a/src/features/office/tasks/useTaskBoardController.ts +++ b/src/features/office/tasks/useTaskBoardController.ts @@ -695,6 +695,7 @@ export const useTaskBoardController = ({ "unknown" | "supported" | "unsupported" >("unknown"); const sharedRefreshInFlightRef = useRef(false); + const lastPersistedTaskBoardSnapshotRef = useRef(null); useEffect(() => { stateRef.current = state; @@ -831,6 +832,15 @@ export const useTaskBoardController = ({ useEffect(() => { if (!hydratedRef.current || !gatewayUrl.trim()) return; + const nextSnapshot = JSON.stringify({ + gatewayUrl, + cards: state.cards, + selectedCardId: state.selectedCardId, + }); + if (lastPersistedTaskBoardSnapshotRef.current === nextSnapshot) { + return; + } + lastPersistedTaskBoardSnapshotRef.current = nextSnapshot; settingsCoordinator.schedulePatch( { taskBoard: { @@ -1141,8 +1151,26 @@ export const useTaskBoardController = ({ [applySharedTaskRecord], ); + const lastDedupeSnapshotRef = useRef(null); useEffect(() => { if (!hydratedRef.current) return; + // Patch Hermes Phase 2: snapshot guard prevents re-entering the dedupe + // loop when state.cards is replaced by a structurally-identical array + // (upstream dispatch churn). Without this, the effect dispatches + // `upsert` to mark duplicates archived → state.cards new ref → + // effect re-runs → "Maximum update depth exceeded" in dev mode. + const snapshot = JSON.stringify( + stateRef.current.cards + .filter((card) => !card.isArchived && card.source === "openclaw_event") + .map((card) => ({ + id: card.id, + title: card.title, + assignedAgentId: card.assignedAgentId ?? null, + externalThreadId: card.externalThreadId ?? null, + })), + ); + if (lastDedupeSnapshotRef.current === snapshot) return; + lastDedupeSnapshotRef.current = snapshot; const grouped = new Map(); for (const card of stateRef.current.cards) { if (card.isArchived || card.source !== "openclaw_event") continue; diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index 50b568e..1b643b6 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -739,6 +739,7 @@ export const useGatewayConnection = ( const [connectErrorCode, setConnectErrorCode] = useState(null); const [settingsLoaded, setSettingsLoaded] = useState(false); const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false); + const lastScheduledGatewaySnapshotRef = useRef(null); const setSelectedAdapterType = useCallback( (value: StudioGatewayAdapterType) => { setSelectedAdapterTypeState(value); @@ -795,18 +796,26 @@ export const useGatewayConnection = ( resolveDefaultStudioGatewayProfile(nextAdapterType, normalizedDefaults); const nextGatewayUrl = selectedProfile.url ?? ""; const nextToken = selectedProfile.token ?? ""; + // Patch Hermes Phase 2: allow auto-connect for auto-managed adapters + // (hermes/openclaw/demo) when a persisted URL exists, even if + // gateway.lastKnownGood.adapterType doesn't match the currently + // selected adapter. Without this, switching to Hermes never + // auto-connects because lastKnownGood is still "openclaw". + const hasPersistedProfileForSelected = + Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url) || + (isAutoManagedAdapter(nextAdapterType) && nextGatewayUrl.trim().length > 0); loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl.trim(), token: nextToken, adapterType: nextAdapterType, profiles: resolvedGatewayProfiles.profiles, - hasLastKnownGood: Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url), + hasLastKnownGood: hasPersistedProfileForSelected, }; setGatewayUrl(nextGatewayUrl); setToken(nextToken); setSelectedAdapterTypeState(nextAdapterType); setAdapterProfiles(resolvedGatewayProfiles.profiles); - setHasLastKnownGoodState(Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url)); + setHasLastKnownGoodState(hasPersistedProfileForSelected); } catch (err) { if (!cancelled) { const message = err instanceof Error ? err.message : "Failed to load gateway settings."; @@ -1106,14 +1115,25 @@ export const useGatewayConnection = ( token: persistToken, }, }; + const nextSnapshot = JSON.stringify({ + gatewayUrl: nextGatewayUrl, + token, + selectedAdapterType, + profiles: nextProfiles, + }); + if (lastScheduledGatewaySnapshotRef.current === nextSnapshot) { + return; + } if ( nextGatewayUrl === baseline.gatewayUrl && token === baseline.token && selectedAdapterType === baseline.adapterType && JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {}) ) { + lastScheduledGatewaySnapshotRef.current = nextSnapshot; return; } + lastScheduledGatewaySnapshotRef.current = nextSnapshot; settingsCoordinator.schedulePatch( { gateway: { @@ -1125,6 +1145,13 @@ export const useGatewayConnection = ( }, 400 ); + loadedGatewaySettings.current = { + gatewayUrl: nextGatewayUrl, + token, + adapterType: selectedAdapterType, + profiles: nextProfiles, + hasLastKnownGood: baseline.hasLastKnownGood, + }; }, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]); const useLocalGatewayDefaults = useCallback(() => { diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts index 2c3aa24..e492334 100644 --- a/src/lib/studio/coordinator.ts +++ b/src/lib/studio/coordinator.ts @@ -353,6 +353,7 @@ const mergeStudioPatch = ( ...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}), ...(next.office ? { office: { ...next.office } } : {}), ...(next.standup ? { standup: { ...next.standup } } : {}), + ...(next.taskBoard ? { taskBoard: { ...next.taskBoard } } : {}), ...(next.officeFloors ? { officeFloors: { ...next.officeFloors } } : {}), }; }