fix: cross-session assistant trigger guard — heartbeat liveness detection
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8cce9889eb
commit
2a0451329b
4 files changed
+130
-6
No files matched your search
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user