252 lines
8.5 KiB
Markdown
252 lines
8.5 KiB
Markdown
# Session ID Architecture
|
|
|
|
## Overview
|
|
|
|
Claude-mem uses **two distinct session IDs** to track conversations and memory:
|
|
|
|
1. **`contentSessionId`** - The user's Claude Code conversation session ID
|
|
2. **`memorySessionId`** - The SDK agent's internal session ID for resume functionality
|
|
|
|
## Critical Architecture
|
|
|
|
### Initialization Flow
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ 1. Hook creates session │
|
|
│ createSDKSession(contentSessionId, project, prompt) │
|
|
│ │
|
|
│ Database state: │
|
|
│ ├─ content_session_id: "user-session-123" │
|
|
│ └─ memory_session_id: NULL (not yet captured) │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ 2. SDKAgent starts, checks hasRealMemorySessionId │
|
|
│ const hasReal = !!memorySessionId │
|
|
│ → FALSE (it's NULL) │
|
|
│ → Resume NOT used (fresh SDK session) │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ 3. First SDK message arrives with session_id │
|
|
│ ensureMemorySessionIdRegistered(sessionDbId, "sdk-gen-abc123") │
|
|
│ │
|
|
│ Database state: │
|
|
│ ├─ content_session_id: "user-session-123" │
|
|
│ └─ memory_session_id: "sdk-gen-abc123" (real!) │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ 4. Subsequent prompts may use resume │
|
|
│ const shouldResume = │
|
|
│ !!memorySessionId && lastPromptNumber > 1 && !forceInit│
|
|
│ → TRUE only for continuation prompts in the same runtime │
|
|
│ → Resume parameter: { resume: "sdk-gen-abc123" } │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Observation Storage
|
|
|
|
**CRITICAL**: Observations are stored with the real `memorySessionId`, NOT `contentSessionId`.
|
|
|
|
```typescript
|
|
// SessionStore.ts
|
|
storeObservation(memorySessionId, project, observation, ...);
|
|
```
|
|
|
|
This means:
|
|
|
|
- Database column: `observations.memory_session_id`
|
|
- Stored value: the captured or synthesized `memorySessionId`
|
|
- Foreign key: References `sdk_sessions.memory_session_id`
|
|
|
|
Observation storage is blocked until a real `memorySessionId` is registered in `sdk_sessions`.
|
|
This is why `SDKAgent` persists the SDK-returned `session_id` immediately through
|
|
`ensureMemorySessionIdRegistered(...)` before any observation insert can succeed.
|
|
|
|
## Key Invariants
|
|
|
|
### 1. NULL-Based Detection
|
|
|
|
```typescript
|
|
const hasRealMemorySessionId = !!session.memorySessionId;
|
|
```
|
|
|
|
- When `memorySessionId` is falsy → Not yet captured
|
|
- When `memorySessionId` is truthy → Real SDK session captured
|
|
|
|
### 2. Resume Safety
|
|
|
|
**NEVER** use `contentSessionId` for resume:
|
|
|
|
```typescript
|
|
// ❌ FORBIDDEN - Would resume user's session instead of memory session!
|
|
query({ resume: contentSessionId })
|
|
|
|
// ✅ CORRECT - Only resume for a continuation prompt in a valid runtime
|
|
query({
|
|
...(
|
|
!!memorySessionId &&
|
|
lastPromptNumber > 1 &&
|
|
!forceInit &&
|
|
{ resume: memorySessionId }
|
|
)
|
|
})
|
|
```
|
|
|
|
`memorySessionId` is necessary but not sufficient.
|
|
Worker restart and crash-recovery paths may still carry a persisted ID while forcing a fresh INIT run.
|
|
|
|
### 3. Session Isolation
|
|
|
|
- Each `contentSessionId` maps to exactly one database session
|
|
- Each database session has one `memorySessionId` (initially NULL, then captured)
|
|
- Observations from different content sessions must NEVER mix
|
|
|
|
### 4. Foreign Key Integrity
|
|
|
|
- Observations reference `sdk_sessions.memory_session_id`
|
|
- Initially, `sdk_sessions.memory_session_id` is NULL (no observations can be stored yet)
|
|
- When SDK session ID is captured, `sdk_sessions.memory_session_id` is set to the real value
|
|
- Observations are stored using that real `memory_session_id`
|
|
- Queries can still find the session from `content_session_id`, but observation rows themselves stay keyed by `memory_session_id`
|
|
|
|
## Testing Strategy
|
|
|
|
The test suite validates all critical invariants:
|
|
|
|
### Test File
|
|
|
|
`tests/session_id_usage_validation.test.ts`
|
|
|
|
### Test Categories
|
|
|
|
1. **NULL-Based Detection** - Validates `hasRealMemorySessionId` logic
|
|
2. **Observation Storage** - Confirms observations use real `memorySessionId` values after registration
|
|
3. **Resume Safety** - Prevents `contentSessionId` and stale INIT sessions from being used for resume
|
|
4. **Cross-Contamination Prevention** - Ensures session isolation
|
|
5. **Foreign Key Integrity** - Validates cascade behavior
|
|
6. **Session Lifecycle** - Tests create → capture → resume flow
|
|
7. **Edge Cases** - Handles NULL, duplicate IDs, etc.
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
# Run all session ID tests
|
|
bun test tests/session_id_usage_validation.test.ts
|
|
|
|
# Run all tests
|
|
bun test
|
|
|
|
# Run with verbose output
|
|
bun test --verbose
|
|
```
|
|
|
|
## Common Pitfalls
|
|
|
|
### ❌ Using memorySessionId for observations
|
|
|
|
```typescript
|
|
// WRONG - Don't store observations before memorySessionId is available
|
|
storeObservation(session.contentSessionId, ...)
|
|
```
|
|
|
|
### ❌ Resuming without checking for NULL
|
|
|
|
```typescript
|
|
// WRONG - memorySessionId alone is not enough
|
|
if (session.memorySessionId) {
|
|
query({ resume: session.memorySessionId })
|
|
}
|
|
```
|
|
|
|
### ❌ Assuming memorySessionId is always set
|
|
|
|
```typescript
|
|
// WRONG - Can be NULL before SDK session is captured
|
|
const resumeId = session.memorySessionId
|
|
```
|
|
|
|
## Correct Usage Patterns
|
|
|
|
### ✅ Storing observations
|
|
|
|
```typescript
|
|
// Only store after a real memorySessionId has been captured or synthesized
|
|
storeObservation(session.memorySessionId, project, obs, ...)
|
|
```
|
|
|
|
### ✅ Checking for real memory session ID
|
|
|
|
```typescript
|
|
const hasRealMemorySessionId = !!session.memorySessionId;
|
|
```
|
|
|
|
### ✅ Using resume parameter
|
|
|
|
```typescript
|
|
query({
|
|
prompt: messageGenerator,
|
|
options: {
|
|
...(
|
|
hasRealMemorySessionId &&
|
|
session.lastPromptNumber > 1 &&
|
|
!session.forceInit &&
|
|
{ resume: session.memorySessionId }
|
|
),
|
|
// ... other options
|
|
}
|
|
})
|
|
```
|
|
|
|
## Debugging Tips
|
|
|
|
### Check session state
|
|
|
|
```sql
|
|
-- See both session IDs
|
|
SELECT
|
|
id,
|
|
content_session_id,
|
|
memory_session_id,
|
|
CASE
|
|
WHEN memory_session_id IS NULL THEN 'NOT_CAPTURED'
|
|
ELSE 'CAPTURED'
|
|
END as state
|
|
FROM sdk_sessions
|
|
WHERE content_session_id = 'your-session-id';
|
|
```
|
|
|
|
### Find orphaned observations
|
|
|
|
```sql
|
|
-- Should return 0 rows if FK integrity is maintained
|
|
SELECT o.*
|
|
FROM observations o
|
|
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
|
WHERE s.id IS NULL;
|
|
```
|
|
|
|
### Verify observation linkage
|
|
|
|
```sql
|
|
-- See which observations belong to a session
|
|
SELECT
|
|
o.id,
|
|
o.title,
|
|
o.memory_session_id,
|
|
s.content_session_id,
|
|
s.memory_session_id as session_memory_id
|
|
FROM observations o
|
|
JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
|
WHERE s.content_session_id = 'your-session-id';
|
|
```
|
|
|
|
## References
|
|
|
|
- **Implementation**: `src/services/worker/SDKAgent.ts` (lines 72-94)
|
|
- **Session Store**: `src/services/sqlite/SessionStore.ts`
|
|
- **Tests**: `tests/session_id_usage_validation.test.ts`
|
|
- **Related Tests**: `tests/session_id_refactor.test.ts`
|