feat: add context sharing and storage consistency (P0)
Session runtime mutex: - Add session_runtime_locks table with TTL-based locking via SQLite single-writer guarantee (transaction: DELETE expired + INSERT, PK conflict = already locked → 409 SESSION_BUSY) - POST /api/chat acquires lock before processing, releases in collectStreamResponse finally block and error catch - Add runtime_status/runtime_updated_at/runtime_error columns to chat_sessions for tracking session state Permission request persistence: - Add permission_requests table with full lifecycle tracking (pending → allow/deny/timeout/aborted) - Dual-write in permission-registry: in-memory Map for real-time resolution + DB for audit and recovery - claude-client canUseTool now persists to DB before SSE event - permission/route.ts validates against DB before resolving: 404 (not found), 409 (already resolved), 410 (waiter gone) Immutable sdk_cwd: - Add sdk_cwd column, snapshot from working_directory at session creation time, never changes afterward - route.ts uses sdk_cwd for SDK cwd to prevent resume breakage when user modifies working_directory - Backfill existing sessions via migration Startup recovery (migrateDb): - Reset running/waiting_permission sessions to idle with error msg - Clear all runtime locks (stale from previous process) - Abort pending permission requests (no in-memory waiter exists) All DB writes are try/catch guarded — failure degrades gracefully without breaking the in-memory hot path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
09908cfcb7
commit
2a7dbd45c2
6 files changed
+364
-12
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { resolvePendingPermission } from '@/lib/permission-registry';
|
||||
import { getPermissionRequest } from '@/lib/db';
|
||||
import type { PermissionResponseRequest } from '@/types';
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
@@ -18,6 +19,21 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate against DB before resolving in-memory
|
||||
const dbRecord = getPermissionRequest(permissionRequestId);
|
||||
if (!dbRecord) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Permission request not found', code: 'NOT_FOUND' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
if (dbRecord.status !== 'pending') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Permission request already resolved (status: ${dbRecord.status})`, code: 'ALREADY_RESOLVED' }),
|
||||
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
let result: PermissionResult;
|
||||
if (decision.behavior === 'allow') {
|
||||
result = {
|
||||
@@ -36,8 +52,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!found) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Permission request not found or already resolved' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||
JSON.stringify({
|
||||
error: 'Permission request exists in DB but the in-memory waiter is gone (process may have restarted)',
|
||||
code: 'WAITER_GONE',
|
||||
}),
|
||||
{ status: 410, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId } from '@/lib/db';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, releaseSessionLock, setSessionRuntimeStatus } from '@/lib/db';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -9,6 +10,9 @@ export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let activeSessionId: string | undefined;
|
||||
let activeLockId: string | undefined;
|
||||
|
||||
try {
|
||||
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string } = await request.json();
|
||||
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend } = body;
|
||||
@@ -31,6 +35,19 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Acquire exclusive lock for this session to prevent concurrent requests
|
||||
const lockId = crypto.randomBytes(8).toString('hex');
|
||||
const lockAcquired = acquireSessionLock(session_id, lockId, `chat-${process.pid}`, 600);
|
||||
if (!lockAcquired) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Session is busy processing another request', code: 'SESSION_BUSY' }),
|
||||
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
activeSessionId = session_id;
|
||||
activeLockId = lockId;
|
||||
setSessionRuntimeStatus(session_id, 'running');
|
||||
|
||||
// Save user message — persist file metadata so attachments survive page reload
|
||||
let savedContent = content;
|
||||
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
|
||||
@@ -168,20 +185,26 @@ export async function POST(request: NextRequest) {
|
||||
sdkSessionId: session.sdk_session_id || undefined,
|
||||
model: effectiveModel,
|
||||
systemPrompt: finalSystemPrompt,
|
||||
workingDirectory: session.working_directory || undefined,
|
||||
workingDirectory: session.sdk_cwd || session.working_directory || undefined,
|
||||
abortController,
|
||||
permissionMode,
|
||||
files: fileAttachments,
|
||||
toolTimeoutSeconds: toolTimeout || 300,
|
||||
provider: resolvedProvider,
|
||||
conversationHistory: historyMsgs,
|
||||
onRuntimeStatusChange: (status: string) => {
|
||||
try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ }
|
||||
},
|
||||
});
|
||||
|
||||
// Tee the stream: one for client, one for collecting the response
|
||||
const [streamForClient, streamForCollect] = stream.tee();
|
||||
|
||||
// Save assistant message in background
|
||||
collectStreamResponse(streamForCollect, session_id);
|
||||
// Save assistant message in background, with cleanup callback to release lock
|
||||
collectStreamResponse(streamForCollect, session_id, () => {
|
||||
releaseSessionLock(session_id, lockId);
|
||||
setSessionRuntimeStatus(session_id, 'idle');
|
||||
});
|
||||
|
||||
return new Response(streamForClient, {
|
||||
headers: {
|
||||
@@ -191,6 +214,14 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Release lock and reset status on error (only if lock was acquired)
|
||||
if (activeSessionId && activeLockId) {
|
||||
try {
|
||||
releaseSessionLock(activeSessionId, activeLockId);
|
||||
setSessionRuntimeStatus(activeSessionId, 'idle', error instanceof Error ? error.message : 'Unknown error');
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
@@ -199,7 +230,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string) {
|
||||
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string, onComplete?: () => void) {
|
||||
const reader = stream.getReader();
|
||||
const contentBlocks: MessageContentBlock[] = [];
|
||||
let currentText = '';
|
||||
@@ -332,5 +363,7 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
|
||||
addMessage(sessionId, 'assistant', content);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
onComplete?.();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user