From 2a0451329bd35af27ed55335ff89e1a256700416 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Thu, 12 Mar 2026 17:27:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20cross-session=20assistant=20trigger=20gu?= =?UTF-8?q?ard=20=E2=80=94=20heartbeat=20liveness=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fixed-timeout stale detection with localStorage heartbeat mechanism - Heartbeat writes {sid, ts} every 10s; isOwnerAlive checks sid matches AND freshness - Stop heartbeat when triggered stream completes (useEffect on !isStreaming) - removeHeartbeatIfOwned: only delete key if it still belongs to this session (prevents new owner's heartbeat from being wiped by old session's cleanup) - Export clearOnboardingHeartbeat with optional sessionId parameter - hook-triggered route sets/clears hookTriggeredAt alongside hookTriggeredSessionId - MiniMax model auto-correction: reset model when not in current provider's list Co-Authored-By: Claude Opus 4.6 --- src/app/api/chat/route.ts | 6 +- src/app/api/workspace/hook-triggered/route.ts | 8 +- src/components/chat/MessageInput.tsx | 11 ++ src/hooks/useAssistantTrigger.ts | 111 +++++++++++++++++- 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 0776417..0de65ba 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -38,8 +38,8 @@ export async function POST(request: NextRequest) { let activeLockId: string | undefined; try { - const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean; displayOverride?: string } = await request.json(); - const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing, displayOverride } = body; + const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean; displayOverride?: string; context_1m?: boolean } = await request.json(); + const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing, displayOverride, context_1m } = body; console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200)); console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none'); @@ -357,6 +357,7 @@ Start by greeting the user and asking the first question. bypassPermissions: session.permission_profile === 'full_access', thinking: thinking as ClaudeStreamOptions['thinking'], effort: effort as ClaudeStreamOptions['effort'], + context1m: context_1m, enableFileCheckpointing: enableFileCheckpointing ?? (effectiveMode === 'code'), autoTrigger: !!autoTrigger, onRuntimeStatusChange: (status: string) => { @@ -648,6 +649,7 @@ async function processCompletionServerSide( if (wsPath) { const state = loadState(wsPath); state.hookTriggeredSessionId = undefined; + state.hookTriggeredAt = undefined; saveState(wsPath, state); } } catch { diff --git a/src/app/api/workspace/hook-triggered/route.ts b/src/app/api/workspace/hook-triggered/route.ts index 412a80e..688e3d5 100644 --- a/src/app/api/workspace/hook-triggered/route.ts +++ b/src/app/api/workspace/hook-triggered/route.ts @@ -11,7 +11,13 @@ export async function POST(request: NextRequest) { const { sessionId } = await request.json(); const state = loadState(workspacePath); // '__clear__' sentinel clears the field (used after onboarding/checkin completes) - state.hookTriggeredSessionId = sessionId === '__clear__' ? undefined : sessionId; + if (sessionId === '__clear__') { + state.hookTriggeredSessionId = undefined; + state.hookTriggeredAt = undefined; + } else { + state.hookTriggeredSessionId = sessionId; + state.hookTriggeredAt = new Date().toISOString(); + } saveState(workspacePath, state); return NextResponse.json({ success: true }); } catch (e) { diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 327a386..8c04ca3 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -82,6 +82,17 @@ export function MessageInput({ // --- Extracted hooks --- const popover = usePopoverState(modelName); const { providerGroups, currentProviderIdValue, modelOptions, currentModelOption } = useProviderModels(providerId, modelName); + + // Auto-correct model when it doesn't exist in the current provider's model list. + // This prevents sending an unsupported model name (e.g. 'opus' to MiniMax which only has 'sonnet'). + useEffect(() => { + if (modelName && modelOptions.length > 0 && !modelOptions.some(m => m.value === modelName)) { + const fallback = modelOptions[0].value; + onModelChange?.(fallback); + onProviderModelChange?.(currentProviderIdValue, fallback); + } + }, [modelName, modelOptions, currentProviderIdValue, onModelChange, onProviderModelChange]); + const { badge, setBadge, cliBadge, setCliBadge, removeBadge, removeCliBadge, hasBadge } = useCommandBadge(textareaRef); const cliToolsFetch = useCliToolsFetch({ diff --git a/src/hooks/useAssistantTrigger.ts b/src/hooks/useAssistantTrigger.ts index 6083dbc..0e46b42 100644 --- a/src/hooks/useAssistantTrigger.ts +++ b/src/hooks/useAssistantTrigger.ts @@ -3,6 +3,71 @@ import type { Message, FileAttachment } from '@/types'; import { getLocalDateString } from '@/lib/utils'; import { startStream } from '@/lib/stream-session-manager'; +// ── localStorage heartbeat for cross-tab liveness detection ── +// The session that owns the onboarding lock writes {sessionId, ts} every 10s. +// Other sessions check this to decide if the owner tab is still alive. +// The heartbeat is scoped: isOwnerAlive(hookTriggeredSessionId) only returns +// true if the heartbeat's sessionId matches, so a stale heartbeat from a +// completed session can't masquerade as a different session's owner. +const HEARTBEAT_KEY = 'codepilot:onboarding-heartbeat'; +const HEARTBEAT_INTERVAL = 10_000; // write every 10s +const HEARTBEAT_STALE_MS = 30_000; // consider dead after 30s without update + +/** Remove the heartbeat key only if it still belongs to the given session. */ +function removeHeartbeatIfOwned(sessionId: string): void { + try { + const raw = localStorage.getItem(HEARTBEAT_KEY); + if (!raw) return; + const { sid } = JSON.parse(raw) as { sid: string }; + if (sid === sessionId) { + localStorage.removeItem(HEARTBEAT_KEY); + } + } catch { /* ignore */ } +} + +function startHeartbeat(sessionId: string): () => void { + if (typeof window === 'undefined') return () => {}; + const write = () => { + try { + localStorage.setItem(HEARTBEAT_KEY, JSON.stringify({ sid: sessionId, ts: Date.now() })); + } catch { /* ignore */ } + }; + write(); + const id = setInterval(write, HEARTBEAT_INTERVAL); + return () => { + clearInterval(id); + removeHeartbeatIfOwned(sessionId); + }; +} + +/** Stop the heartbeat externally (called when onboarding/check-in completes). */ +export function clearOnboardingHeartbeat(sessionId?: string): void { + if (typeof window === 'undefined') return; + if (sessionId) { + removeHeartbeatIfOwned(sessionId); + } else { + // Legacy fallback: unconditional remove (only when caller doesn't know sessionId) + try { localStorage.removeItem(HEARTBEAT_KEY); } catch { /* ignore */ } + } +} + +/** + * Check if the tab owning the given sessionId is still alive. + * Returns false if the heartbeat is stale, missing, or belongs to a different session. + */ +function isOwnerAlive(expectedSessionId: string): boolean { + if (typeof window === 'undefined') return false; + try { + const raw = localStorage.getItem(HEARTBEAT_KEY); + if (!raw) return false; + const { sid, ts } = JSON.parse(raw) as { sid: string; ts: number }; + if (sid !== expectedSessionId) return false; + return Date.now() - ts < HEARTBEAT_STALE_MS; + } catch { + return false; + } +} + interface UseAssistantTriggerOpts { sessionId: string; workingDirectory?: string; @@ -31,6 +96,24 @@ export function useAssistantTrigger({ initMetaRef, }: UseAssistantTriggerOpts): () => void { const assistantTriggerFiredRef = useRef(false); + const stopHeartbeatRef = useRef<(() => void) | null>(null); + + // Clean up heartbeat on unmount (tab close, navigation away) + useEffect(() => { + return () => { + stopHeartbeatRef.current?.(); + stopHeartbeatRef.current = null; + }; + }, []); + + // Stop heartbeat when the triggered stream finishes (onboarding/check-in completed or errored). + // assistantTriggerFiredRef means WE started the stream; !isStreaming means it's done. + useEffect(() => { + if (assistantTriggerFiredRef.current && !isStreaming && stopHeartbeatRef.current) { + stopHeartbeatRef.current(); + stopHeartbeatRef.current = null; + } + }, [isStreaming]); const checkAssistantTrigger = useCallback(async () => { // Don't trigger if already streaming or already triggered in this mount @@ -48,9 +131,26 @@ export function useAssistantTrigger({ const state = data.state; if (!state) return; - // Check hookTriggeredSessionId: if this session already has a trigger in progress - // AND there are existing messages (conversation started), skip to avoid re-triggering. - // If the session has no messages, the previous trigger may have failed — allow retry. + // Guard against duplicate triggers across sessions: + // 1. If ANOTHER session owns the lock, check if its tab is still alive via + // localStorage heartbeat. No fixed timeout — the heartbeat stops immediately + // when the tab closes/crashes, and we detect it within 30s. + // 2. If THIS session already triggered and has messages, don't re-trigger. + if (state.hookTriggeredSessionId && state.hookTriggeredSessionId !== sessionId) { + if (isOwnerAlive(state.hookTriggeredSessionId)) { + return; // Owning tab is still open, don't interfere + } + // Owner tab is gone — clear the stale lock and proceed + try { + await fetch('/api/workspace/hook-triggered', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: '__clear__' }), + }); + } catch { + return; // Can't clear, err on the safe side + } + } if (state.hookTriggeredSessionId === sessionId && initialMessages.length > 0) return; const today = getLocalDateString(); @@ -107,6 +207,11 @@ export function useAssistantTrigger({ // Mark fired so we don't re-trigger on focus/re-render assistantTriggerFiredRef.current = true; + // Start heartbeat BEFORE marking persistent state — so the heartbeat is + // already running when other tabs check liveness. + stopHeartbeatRef.current?.(); + stopHeartbeatRef.current = startHeartbeat(sessionId); + // Mark in persistent state to prevent duplicate triggers across page reloads await fetch('/api/workspace/hook-triggered', { method: 'POST',