fix: CAS lock for assistant trigger, LIKE escape, provider options race

Assistant trigger CAS (P1):
- hook-triggered route: added expectedOwner param for compare-and-swap —
  rejects if current owner doesn't match, closing the race where two tabs
  both clear then both claim the lock
- useAssistantTrigger: clear sends expectedOwner (stale session), set sends
  expectedOwner: null; non-2xx responses now bail instead of falling through
- Server-side completion (chat/route.ts): only clears lock if owner matches
  current sessionId, preventing completed sessions from wiping active locks
- Compensation branch: clear now passes expectedOwner to avoid bypassing CAS

Image-gen LIKE escape (P2):
- db.ts: escape backslashes before % and _ since ESCAPE '\' makes \ the
  escape char — prevents Windows paths / escaped JSON from corrupting the
  LIKE pattern and silently falling back to prompt-prefix matching

Provider options fetch race (P2):
- page.tsx + ChatView.tsx: provider options fetch now uses AbortController
  with cleanup on dependency change — prevents stale slow responses from
  overwriting the current provider's thinking/context_1m state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-12 21:29:10 +08:00
1 parent 7b48ee1d2a
commit e2a0f49843
6 files changed
+87 -24

No files matched your search

+8 -4
View File
@@ -641,16 +641,20 @@ async function processCompletionServerSide(
console.log('[chat API] Server-side checkin completion succeeded');
}
// Clear hookTriggeredSessionId directly (no HTTP needed)
// Clear hookTriggeredSessionId directly (no HTTP needed).
// CAS: only clear if we are still the owner — prevents wiping another
// tab's legitimate lock when completions arrive out of order.
try {
const { loadState, saveState } = await import('@/lib/assistant-workspace');
const { getSetting: getSettingDirect } = await import('@/lib/db');
const wsPath = getSettingDirect('assistant_workspace_path');
if (wsPath) {
const state = loadState(wsPath);
state.hookTriggeredSessionId = undefined;
state.hookTriggeredAt = undefined;
saveState(wsPath, state);
if (state.hookTriggeredSessionId === sessionId || !state.hookTriggeredSessionId) {
state.hookTriggeredSessionId = undefined;
state.hookTriggeredAt = undefined;
saveState(wsPath, state);
}
}
} catch {
// Best effort
+12 -1
View File
@@ -8,8 +8,19 @@ export async function POST(request: NextRequest) {
if (!workspacePath) {
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
}
const { sessionId } = await request.json();
const { sessionId, expectedOwner } = await request.json();
const state = loadState(workspacePath);
// Compare-and-swap: if expectedOwner is provided, only proceed when the
// current owner matches. This prevents two tabs that both see the same
// stale owner from racing through clear → set sequences.
if (expectedOwner !== undefined) {
const currentOwner = state.hookTriggeredSessionId ?? null;
if (currentOwner !== expectedOwner) {
return NextResponse.json({ success: false, reason: 'owner_mismatch', currentOwner });
}
}
// '__clear__' sentinel clears the field (used after onboarding/checkin completes)
if (sessionId === '__clear__') {
state.hookTriggeredSessionId = undefined;
+8 -4
View File
@@ -47,16 +47,20 @@ export default function NewChatPage() {
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
const [context1m, setContext1m] = useState(false);
// Fetch provider-specific options
// Fetch provider-specific options (with abort to prevent stale responses on fast switch)
useEffect(() => {
const pid = currentProviderId || 'env';
fetch(`/api/providers/options?providerId=${encodeURIComponent(pid)}`)
const controller = new AbortController();
fetch(`/api/providers/options?providerId=${encodeURIComponent(pid)}`, { signal: controller.signal })
.then(r => r.ok ? r.json() : null)
.then(data => {
setThinkingMode(data?.options?.thinking_mode || 'adaptive');
setContext1m(!!data?.options?.context_1m);
if (!controller.signal.aborted) {
setThinkingMode(data?.options?.thinking_mode || 'adaptive');
setContext1m(!!data?.options?.context_1m);
}
})
.catch(() => {});
return () => controller.abort();
}, [currentProviderId]);
const stopStreaming = useCallback(() => {
+8 -4
View File
@@ -59,16 +59,20 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
useEffect(() => { if (modelName) setCurrentModel(modelName); }, [modelName]);
useEffect(() => { if (providerId) setCurrentProviderId(providerId); }, [providerId]);
// Fetch provider-specific options (thinking mode + 1M context)
// Fetch provider-specific options (with abort to prevent stale responses on fast switch)
useEffect(() => {
const pid = currentProviderId || 'env';
fetch(`/api/providers/options?providerId=${encodeURIComponent(pid)}`)
const controller = new AbortController();
fetch(`/api/providers/options?providerId=${encodeURIComponent(pid)}`, { signal: controller.signal })
.then(r => r.ok ? r.json() : null)
.then(data => {
setThinkingMode(data?.options?.thinking_mode || 'adaptive');
setContext1m(!!data?.options?.context_1m);
if (!controller.signal.aborted) {
setThinkingMode(data?.options?.thinking_mode || 'adaptive');
setContext1m(!!data?.options?.context_1m);
}
})
.catch(() => {});
return () => controller.abort();
}, [currentProviderId]);
useEffect(() => { if (initialPermissionProfile) setPermissionProfile(initialPermissionProfile); }, [initialPermissionProfile]);
+50 -10
View File
@@ -140,13 +140,24 @@ export function useAssistantTrigger({
if (isOwnerAlive(state.hookTriggeredSessionId)) {
return; // Owning tab is still open, don't interfere
}
// Owner tab is gone — clear the stale lock and proceed
// Owner tab is gone — atomically clear the stale lock (CAS: only if owner
// is still the stale session we observed). If another tab already swapped
// in, the server returns owner_mismatch and we bail out.
try {
await fetch('/api/workspace/hook-triggered', {
const clearRes = await fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: '__clear__' }),
body: JSON.stringify({
sessionId: '__clear__',
expectedOwner: state.hookTriggeredSessionId,
}),
});
if (clearRes.ok) {
const clearData = await clearRes.json();
if (!clearData.success) return; // Another tab won the race
} else {
return;
}
} catch {
return; // Can't clear, err on the safe side
}
@@ -181,7 +192,10 @@ export function useAssistantTrigger({
await fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: '__clear__' }),
body: JSON.stringify({
sessionId: '__clear__',
expectedOwner: state.hookTriggeredSessionId || null,
}),
}).catch(() => {});
console.log('[useAssistantTrigger] Onboarding compensation succeeded, skipping re-trigger');
return; // Don't re-trigger onboarding
@@ -212,12 +226,38 @@ export function useAssistantTrigger({
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
// Mark in persistent state to prevent duplicate triggers across page reloads.
// CAS: only set owner if currently unowned (null). If another tab set itself
// as owner between our clear and this call, the server rejects and we bail.
try {
const setRes = await fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, expectedOwner: null }),
});
// Bail on any non-2xx (including 500) or CAS rejection — never
// proceed to startStream without a confirmed lock.
if (!setRes.ok) {
assistantTriggerFiredRef.current = false;
stopHeartbeatRef.current?.();
stopHeartbeatRef.current = null;
return;
}
const setData = await setRes.json();
if (!setData.success) {
// Lost race — another tab claimed ownership
assistantTriggerFiredRef.current = false;
stopHeartbeatRef.current?.();
stopHeartbeatRef.current = null;
return;
}
} catch {
// Network error — bail out
assistantTriggerFiredRef.current = false;
stopHeartbeatRef.current?.();
stopHeartbeatRef.current = null;
return;
}
// Use autoTrigger: the message is invisible (no user bubble, no title update)
const triggerMsg = needsOnboarding
+1 -1
View File
@@ -893,7 +893,7 @@ export function updateMessageBySessionAndHint(
// Strategy 1: Exact match on the raw ```image-gen-request...``` block content.
// This is unambiguous even when multiple requests share the same prompt.
if (rawRequestBlock) {
const escaped = rawRequestBlock.replace(/%/g, '\\%').replace(/_/g, '\\_');
const escaped = rawRequestBlock.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
const row = db.prepare(
"SELECT id FROM messages WHERE session_id = ? AND role = 'assistant' AND content LIKE ? ESCAPE '\\' AND content NOT LIKE '%image-gen-result%' ORDER BY created_at DESC LIMIT 1"
).get(sessionId, `%${escaped}%`) as { id: string } | undefined;