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:
7418andClaude Opus 4.6 committed 2026-03-12 17:27:00 +08:00
1 parent 8cce9889eb
commit 2a0451329b
4 files changed
+130 -6

No files matched your search

+4 -2
View File
@@ -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 {
@@ -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) {
+11
View File
@@ -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({
+108 -3
View File
@@ -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',