Control UI Sessions Dropdown Shows Deleted Sessions from Stale Cache
After deleting a Discord session via API, the Control UI continues to display the deleted session in the dropdown due to stale frontend caching and missing cache invalidation on session deletion.
π Symptoms
Visual Manifestation
The user navigates to the Control UI at http://127.0.0.1:18789/ and observes the following behavior:
- The sessions dropdown menu displays a Discord session that was previously deleted
- The session key `agent:main:discord:channel:1481906920334950492` appears selectable despite no longer existing in the backend
- Selecting the stale session entry produces no action or an error response
Technical Reproduction Steps
Step 1: Verify initial state
cat ~/.openclaw/agents/main/sessions/sessions.json | jq '.sessions | length'
# Returns: 3Step 2: Delete a session via API
curl -X PATCH http://127.0.0.1:18792/api/sessions \
-H "Content-Type: application/json" \
-d '{"action": "delete", "sessionId": "agent:main:discord:channel:1481906920334950492"}'Step 3: Confirm backend deletion
cat ~/.openclaw/agents/main/sessions/sessions.json | jq '.sessions[] | select(.id == "agent:main:discord:channel:1481906920334950492")'
# Returns: null (session no longer exists)Step 4: Refresh Control UI dropdown
# After refreshing http://127.0.0.1:18789/
# The dropdown still shows the deleted sessionError Indicators
- Network Tab: GET request to `/api/sessions` returns updated list without deleted session
- Console Warning: No JavaScript errors thrown, but session selection fails silently
- WebSocket: No session removal event broadcasted to connected UI clients
π§ Root Cause
Architectural Analysis
The bug stems from a dual-layer caching problem in the OpenClaw Control UI architecture:
Layer 1: Frontend State Cache
The React/Vue frontend maintains a local state cache of sessions in useSessions or equivalent hook:
// Pseudocode representation of the problematic pattern
const [sessions, setSessions] = useState([]);
const [lastFetch, setLastFetch] = useState(0);
// Problem: Fetch only occurs on mount, not on external changes
useEffect(() => {
fetch('/api/sessions').then(setSessions);
}, []);The component lacks:
- Polling interval to refresh session list
- WebSocket subscription to `session:deleted` events
- Invalidation trigger when deletion API returns success
Layer 2: In-Memory Server Cache
The Gateway server caches the sessions list in memory:
// src/gateway/session-manager.ts (hypothetical)
class SessionManager {
private cache: Map<string, Session>;
async deleteSession(id: string) {
await this.backend.delete(id); // Updates sessions.json
this.cache.delete(id); // Updates in-memory cache
// BUG: Does not emit 'session:deleted' WebSocket event
}
}The deleteSession method fails to:
- Broadcast a `session:deleted` event via WebSocket to all connected UI clients
- Expose an SSE endpoint for real-time session updates
- Implement a pub/sub mechanism for cache invalidation
Failure Sequence
- User deletes session via `/new` command or `sessions.patch` API
- Gateway updates `sessions.json` on disk
- Gateway invalidates internal in-memory cache
- FAILURE: No WebSocket `session:deleted` event is emitted
- FAILURE: Connected Control UI clients retain stale `sessions` state
- User sees ghost session in dropdown until page refresh (which may also use cached data)
Relevant Code Paths
| Component | File | Issue |
|---|---|---|
| Gateway Session Deletion | src/gateway/session-manager.ts | Missing emit('session:deleted') call |
| Frontend Session Hook | src/ui/hooks/useSessions.ts | No WebSocket subscription for live updates |
| Session API Route | src/gateway/api/sessions.ts | Returns success without broadcasting event |
π οΈ Step-by-Step Fix
Fix A: Add WebSocket Event Broadcast (Gateway Side)
File: src/gateway/session-manager.ts
Before:
async deleteSession(id: string): Promise<boolean> {
const session = this.cache.get(id);
if (!session) return false;
await this.persist(); // Updates sessions.json
this.cache.delete(id);
return true;
}After:
async deleteSession(id: string): Promise<boolean> {
const session = this.cache.get(id);
if (!session) return false;
await this.persist(); // Updates sessions.json
this.cache.delete(id);
// Broadcast deletion event to all connected UI clients
this.emit('session:deleted', { sessionId: id, timestamp: Date.now() });
return true;
}Fix B: Subscribe to WebSocket Events (Frontend Side)
File: src/ui/hooks/useSessions.ts
Before:
export function useSessions() {
const [sessions, setSessions] = useState<Session[]>([]);
useEffect(() => {
fetch('/api/sessions').then(r => r.json()).then(setSessions);
}, []);
return { sessions };
}After:
export function useSessions() {
const [sessions, setSessions] = useState<Session[]>([]);
useEffect(() => {
// Initial fetch
fetch('/api/sessions').then(r => r.json()).then(setSessions);
// WebSocket subscription for real-time updates
const ws = new WebSocket('ws://127.0.0.1:18793/events');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'session:deleted') {
setSessions(prev => prev.filter(s => s.id !== data.sessionId));
}
if (data.type === 'session:created') {
setSessions(prev => [...prev, data.session]);
}
};
return () => ws.close();
}, []);
return { sessions };
}Fix C: Client-Side Cache Busting (Alternative/Additional)
File: src/ui/components/SessionsDropdown.tsx
Before:
const sessions = useSessions(); // Static snapshot
return (
<select>
{sessions.map(s => <option key={s.id}>{s.name}</option>)}
</select>
);After:
const sessions = useSessions();
const [lastRefresh, setLastRefresh] = useState(Date.now());
// Force refresh when gaining focus (user clicks dropdown)
const handleDropdownFocus = async () => {
const response = await fetch('/api/sessions?_=' + Date.now());
const freshSessions = await response.json();
setSessions(freshSessions);
setLastRefresh(Date.now());
};
return (
<select onFocus={handleDropdownFocus}>
{sessions.map(s => <option key={s.id}>{s.name}</option>)}
</select>
);Fix D: API Response Enhancement (Optional)
File: src/gateway/api/sessions.ts
Before:
router.delete('/:sessionId', async (ctx) => {
await sessionManager.deleteSession(ctx.params.sessionId);
ctx.status = 204;
});After:
router.delete('/:sessionId', async (ctx) => {
const deleted = await sessionManager.deleteSession(ctx.params.sessionId);
if (!deleted) {
ctx.status = 404;
ctx.body = { error: 'Session not found' };
return;
}
// Emit WebSocket event for real-time UI updates
ctx.ws.send(JSON.stringify({
type: 'session:deleted',
sessionId: ctx.params.sessionId
}));
ctx.status = 204;
});π§ͺ Verification
Test 1: Backend Session Deletion
# 1. Create a test session (if needed)
curl -X POST http://127.0.0.1:18792/api/sessions \
-H "Content-Type: application/json" \
-d '{"type": "discord:channel", "name": "test-session"}'
# 2. Verify session exists
curl http://127.0.0.1:18792/api/sessions | jq '.[] | .id'
# 3. Delete the session
curl -X DELETE http://127.0.0.1:18792/api/sessions/TEST_SESSION_ID
# 4. Verify deletion in backend storage
cat ~/.openclaw/agents/main/sessions/sessions.json | jq '.sessions'
# Expected: Session ID should not appear in outputTest 2: WebSocket Event Emission
# 1. Connect WebSocket client to event stream
node -e "
const ws = new WebSocket('ws://127.0.0.1:18793/events');
ws.onmessage = (e) => console.log('Event:', JSON.parse(e.data));
ws.onopen = () => console.log('Connected');
"
# 2. Delete a session from another terminal
curl -X DELETE http://127.0.0.1:18792/api/sessions/TARGET_SESSION_ID
# 3. Expected WebSocket output:
# Event: { type: 'session:deleted', sessionId: 'TARGET_SESSION_ID', timestamp: 1234567890 }Test 3: Control UI Real-Time Update
# 1. Open Control UI in browser: http://127.0.0.1:18789/
# 2. Open Browser DevTools > Console
# 3. Verify session list loads
console.log('Sessions:', window.__OPENCLAW_SESSIONS__);
# 4. Delete session via API
curl -X DELETE http://127.0.0.1:18792/api/sessions/TARGET_SESSION_ID
# 5. Verify dropdown updates automatically (within 500ms)
# Expected: Deleted session disappears from dropdown without page refreshTest 4: Verification Checklist
| Checkpoint | Command/Action | Expected Result |
|---|---|---|
| Backend deletion | cat sessions.json | Session key absent |
| WebSocket event | WebSocket listener | session:deleted event received |
| UI state update | Observe dropdown | Session removed within 500ms |
| Error resilience | Delete non-existent session | 404 response, no UI crash |
Exit Code Verification
# Successful deletion returns 204
curl -s -o /dev/null -w "%{http_code}" -X DELETE \
http://127.0.0.1:18792/api/sessions/EXISTING_ID
# Expected: 204
# Non-existent session returns 404
curl -s -o /dev/null -w "%{http_code}" -X DELETE \
http://127.0.0.1:18792/api/sessions/NONEXISTENT_ID
# Expected: 404β οΈ Common Pitfalls
1. WebSocket Connection Not Initializing
Symptom: UI shows “Connecting…” indefinitely, sessions never load.
Cause: WebSocket server not running on expected port (18793).
# Verify WebSocket port availability
lsof -i :18793
# Expected: Node process listening on port 18793Resolution: Ensure Gateway starts WebSocket server before HTTP API:
// Correct startup order in src/gateway/index.ts
const wsServer = new WebSocketServer({ port: 18793 });
const httpServer = new HttpServer({ port: 18792 });
await Promise.all([wsServer.start(), httpServer.start()]);2. Race Condition on Rapid Deletions
Symptom: Multiple rapid deletions cause UI to show inconsistent state.
Cause: WebSocket events fire before React state batch update completes.
Resolution: Implement optimistic UI updates with reconciliation:
ws.onmessage = (e) => {
const { type, sessionId } = JSON.parse(e.data);
if (type === 'session:deleted') {
// Optimistically remove, then verify with fresh fetch
setSessions(prev => prev.filter(s => s.id !== sessionId));
fetchFreshSessions(); // Debounced to prevent flooding
}
};3. CORS Blocking WebSocket on Remote Access
Symptom: Works on localhost but fails when accessing via network IP.
Cause: WebSocket CORS headers not configured.
# Test CORS preflight
curl -H "Origin: http://192.168.1.100:18789" \
-X OPTIONS ws://127.0.0.1:18793/events
# Expected: 101 Switching ProtocolsResolution: Configure WebSocket server with CORS:
const wsServer = new WebSocketServer({
port: 18793,
cors: {
origin: ['http://127.0.0.1:18789', 'http://localhost:18789'],
credentials: true
}
});4. Sessions File Permissions (Linux/macOS)
Symptom: “Permission denied” when deleting sessions.
Cause: sessions.json owned by different user or has incorrect permissions.
# Check permissions
ls -la ~/.openclaw/agents/main/sessions/sessions.json
# Fix permissions (if needed)
chmod 644 ~/.openclaw/agents/main/sessions/sessions.json
chown $USER:staff ~/.openclaw/agents/main/sessions/sessions.json5. Docker Volume Mounting Issues
Symptom: Sessions persist after container restart but changes not reflected in UI.
Cause: Docker volume not properly syncing file changes.
# Verify volume mount
docker inspect openclaw-agent --format '{{json .Mounts}}' | jq
# Check if sessions.json exists inside container
docker exec openclaw-agent ls -la /app/.openclaw/agents/main/sessions/Resolution: Use bind mounts with proper synchronization:
docker run -v $(pwd)/openclaw-data:/app/.openclaw:rw ...
# NOT: docker run -v openclaw-data:/app/.openclaw:ro6. Browser Cache (Force Refresh Required)
Symptom: Old UI JavaScript bundles served, missing WebSocket subscription code.
Cause: Browser caches old build assets.
# Hard refresh instructions for users
# Chrome: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (macOS)
# Firefox: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (macOS)
# Clear service worker cache
chrome://inspect/#service-workers -> Unregisterπ Related Errors
E_SESSION_NOT_FOUNDβ Returned when attempting operations on deleted session. Verify session exists via `GET /api/sessions` before performing actions.ECONNREFUSEDβ Gateway not listening on expected port. Check that `openclaw-gateway` process is running: `ps aux | grep openclaw-gateway`.WS_CONNECTION_FAILEDβ WebSocket handshake rejected. Ensure WebSocket server started successfully and CORS headers are configured for the UI origin.E_SESSION_CACHE_STALEβ Internal error when in-memory cache diverges from `sessions.json`. Restart gateway service to resync: `openclaw-cli gateway restart`.- Issue: Control UI sessions dropdown unresponsive after session switch β Related to the same caching mechanism. Fix involves identical WebSocket broadcast on `session:switched` event.
- Issue: New sessions not appearing in Control UI until refresh β Inverse manifestation of this bug. Sessions created via CLI `/new` not reflected in UI dropdown until page refresh.
- Issue: Race condition in session list updates β Multiple rapid create/delete operations can cause UI to temporarily show incorrect session counts. Implement debounced fetch with optimistic locking.
- Historical: sessions.json schema mismatch β Prior versions used different session key format (`discord:channel:ID` vs `agent:main:discord:channel:ID`). Ensure UI correctly parses legacy format during migration.
Cross-Reference Matrix
| Issue | Root Cause | Fix Area |
|---|---|---|
| Ghost sessions in dropdown | Missing WebSocket broadcast | Gateway event emitter |
| UI not updating on delete | No subscription to events | Frontend hook |
| Stale data on page reload | Missing cache headers | HTTP API response |
| Inconsistent multi-tab state | No broadcast coordination | WebSocket pub/sub |