fix(office): unblock Hermes adapter + stop persistence loops (#140)

* 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
This commit is contained in:
JimmyBlanquet
2026-05-30 15:44:12 -05:00
committed by GitHub
parent 17af2f0851
commit 40be0d5253
4 changed files with 68 additions and 5 deletions
+10 -3
View File
@@ -8,7 +8,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter } from "next/navigation";
import { MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Mic } from "lucide-react"; import { MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Mic } from "lucide-react";
import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D"; import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D";
import type { OfficeAgent } from "@/features/retro-office/core/types"; import type { OfficeAgent } from "@/features/retro-office/core/types";
@@ -953,8 +953,15 @@ type OfficeScreenProps = {
export function OfficeScreen({ export function OfficeScreen({
showOpenClawConsole = true, showOpenClawConsole = true,
}: OfficeScreenProps) { }: OfficeScreenProps) {
const searchParams = useSearchParams(); // Patch Hermes Phase 2: avoid useSearchParams() at component root — it
const debugEnabled = searchParams.get("officeDebug") === "1"; // 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(() => const [settingsCoordinator] = useState(() =>
createStudioSettingsCoordinator(), createStudioSettingsCoordinator(),
); );
@@ -695,6 +695,7 @@ export const useTaskBoardController = ({
"unknown" | "supported" | "unsupported" "unknown" | "supported" | "unsupported"
>("unknown"); >("unknown");
const sharedRefreshInFlightRef = useRef(false); const sharedRefreshInFlightRef = useRef(false);
const lastPersistedTaskBoardSnapshotRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
stateRef.current = state; stateRef.current = state;
@@ -831,6 +832,15 @@ export const useTaskBoardController = ({
useEffect(() => { useEffect(() => {
if (!hydratedRef.current || !gatewayUrl.trim()) return; 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( settingsCoordinator.schedulePatch(
{ {
taskBoard: { taskBoard: {
@@ -1141,8 +1151,26 @@ export const useTaskBoardController = ({
[applySharedTaskRecord], [applySharedTaskRecord],
); );
const lastDedupeSnapshotRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!hydratedRef.current) return; 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<string, TaskBoardCard[]>(); const grouped = new Map<string, TaskBoardCard[]>();
for (const card of stateRef.current.cards) { for (const card of stateRef.current.cards) {
if (card.isArchived || card.source !== "openclaw_event") continue; if (card.isArchived || card.source !== "openclaw_event") continue;
+29 -2
View File
@@ -739,6 +739,7 @@ export const useGatewayConnection = (
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null); const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false);
const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false); const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false);
const lastScheduledGatewaySnapshotRef = useRef<string | null>(null);
const setSelectedAdapterType = useCallback( const setSelectedAdapterType = useCallback(
(value: StudioGatewayAdapterType) => { (value: StudioGatewayAdapterType) => {
setSelectedAdapterTypeState(value); setSelectedAdapterTypeState(value);
@@ -795,18 +796,26 @@ export const useGatewayConnection = (
resolveDefaultStudioGatewayProfile(nextAdapterType, normalizedDefaults); resolveDefaultStudioGatewayProfile(nextAdapterType, normalizedDefaults);
const nextGatewayUrl = selectedProfile.url ?? ""; const nextGatewayUrl = selectedProfile.url ?? "";
const nextToken = selectedProfile.token ?? ""; 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 = { loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl.trim(), gatewayUrl: nextGatewayUrl.trim(),
token: nextToken, token: nextToken,
adapterType: nextAdapterType, adapterType: nextAdapterType,
profiles: resolvedGatewayProfiles.profiles, profiles: resolvedGatewayProfiles.profiles,
hasLastKnownGood: Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url), hasLastKnownGood: hasPersistedProfileForSelected,
}; };
setGatewayUrl(nextGatewayUrl); setGatewayUrl(nextGatewayUrl);
setToken(nextToken); setToken(nextToken);
setSelectedAdapterTypeState(nextAdapterType); setSelectedAdapterTypeState(nextAdapterType);
setAdapterProfiles(resolvedGatewayProfiles.profiles); setAdapterProfiles(resolvedGatewayProfiles.profiles);
setHasLastKnownGoodState(Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url)); setHasLastKnownGoodState(hasPersistedProfileForSelected);
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
const message = err instanceof Error ? err.message : "Failed to load gateway settings."; const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
@@ -1106,14 +1115,25 @@ export const useGatewayConnection = (
token: persistToken, token: persistToken,
}, },
}; };
const nextSnapshot = JSON.stringify({
gatewayUrl: nextGatewayUrl,
token,
selectedAdapterType,
profiles: nextProfiles,
});
if (lastScheduledGatewaySnapshotRef.current === nextSnapshot) {
return;
}
if ( if (
nextGatewayUrl === baseline.gatewayUrl && nextGatewayUrl === baseline.gatewayUrl &&
token === baseline.token && token === baseline.token &&
selectedAdapterType === baseline.adapterType && selectedAdapterType === baseline.adapterType &&
JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {}) JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {})
) { ) {
lastScheduledGatewaySnapshotRef.current = nextSnapshot;
return; return;
} }
lastScheduledGatewaySnapshotRef.current = nextSnapshot;
settingsCoordinator.schedulePatch( settingsCoordinator.schedulePatch(
{ {
gateway: { gateway: {
@@ -1125,6 +1145,13 @@ export const useGatewayConnection = (
}, },
400 400
); );
loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl,
token,
adapterType: selectedAdapterType,
profiles: nextProfiles,
hasLastKnownGood: baseline.hasLastKnownGood,
};
}, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]); }, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]);
const useLocalGatewayDefaults = useCallback(() => { const useLocalGatewayDefaults = useCallback(() => {
+1
View File
@@ -353,6 +353,7 @@ const mergeStudioPatch = (
...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}), ...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}),
...(next.office ? { office: { ...next.office } } : {}), ...(next.office ? { office: { ...next.office } } : {}),
...(next.standup ? { standup: { ...next.standup } } : {}), ...(next.standup ? { standup: { ...next.standup } } : {}),
...(next.taskBoard ? { taskBoard: { ...next.taskBoard } } : {}),
...(next.officeFloors ? { officeFloors: { ...next.officeFloors } } : {}), ...(next.officeFloors ? { officeFloors: { ...next.officeFloors } } : {}),
}; };
} }