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:
7418andClaude Opus 4.6 committed 2026-02-27 12:20:01 +08:00
1 parent 09908cfcb7
commit 2a7dbd45c2
6 files changed
+364 -12

No files matched your search

+21 -2
View File
@@ -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' } },
);
}
+38 -5
View File
@@ -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?.();
}
}