Files
openchamber/packages/web/server/lib/opencode/session-runtime.js
T
Bohdan Triapitsyn 0c8301d579 fix: recover stuck live session updates
Reconnects and resyncs active sessions when live updates stall
Normalizes synthetic session status events
Uses authoritative status snapshots to clear stale busy states
2026-05-24 19:17:04 +03:00

357 lines
11 KiB
JavaScript

const SESSION_COOLDOWN_DURATION_MS = 2000;
const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const extractSessionStatusUpdate = (payload) => {
if (!payload || payload.type !== 'session.status') {
return null;
}
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
// Canonical OpenCode schema uses properties.status.type. Keep legacy info.type fallback for compatibility.
const type = typeof status.type === 'string'
? status.type.trim()
: (typeof info.type === 'string' ? info.type.trim() : '');
if (!sessionId || !type) {
return null;
}
return {
sessionId,
type,
eventId: typeof payload.id === 'string' ? payload.id : '',
attempt: typeof status.attempt === 'number'
? status.attempt
: (typeof info.attempt === 'number' ? info.attempt : undefined),
message: typeof status.message === 'string'
? status.message
: (typeof info.message === 'string' ? info.message : undefined),
next: typeof status.next === 'number'
? status.next
: (typeof info.next === 'number' ? info.next : undefined),
};
};
const deriveSessionActivityTransitions = (payload) => {
const update = extractSessionStatusUpdate(payload);
if (!update) {
return [];
}
if (update.type === 'busy' || update.type === 'retry') {
return [{ sessionId: update.sessionId, phase: 'busy' }];
}
if (update.type === 'idle') {
return [{ sessionId: update.sessionId, phase: 'cooldown' }];
}
return [];
};
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, broadcastEvent }) => {
const sessionActivityPhases = new Map();
const sessionActivityCooldowns = new Map();
const sessionStates = new Map();
const sessionAttentionStates = new Map();
const getOrCreateAttentionState = (sessionId) => {
if (!sessionId || typeof sessionId !== 'string') return null;
let state = sessionAttentionStates.get(sessionId);
if (!state) {
state = {
needsAttention: false,
lastUserMessageAt: null,
lastStatusChangeAt: Date.now(),
viewedByClients: new Set(),
status: 'idle',
};
sessionAttentionStates.set(sessionId, state);
}
return state;
};
const setSessionActivityPhase = (sessionId, phase) => {
if (!sessionId || typeof sessionId !== 'string') return false;
const current = sessionActivityPhases.get(sessionId);
if (current?.phase === phase) return false;
if (phase === 'cooldown' && current?.phase !== 'busy') {
return false;
}
const existingTimer = sessionActivityCooldowns.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
sessionActivityCooldowns.delete(sessionId);
}
sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() });
if (phase === 'cooldown') {
const timer = setTimeout(() => {
const now = sessionActivityPhases.get(sessionId);
if (now?.phase === 'cooldown') {
setSessionActivityPhase(sessionId, 'idle');
return;
}
sessionActivityCooldowns.delete(sessionId);
}, SESSION_COOLDOWN_DURATION_MS);
sessionActivityCooldowns.set(sessionId, timer);
}
if (typeof broadcastEvent === 'function') {
broadcastEvent({
type: 'openchamber:session-activity',
properties: {
sessionId,
phase,
},
});
}
return true;
};
const updateSessionAttentionStatus = (sessionId, status) => {
const state = getOrCreateAttentionState(sessionId);
if (!state) return;
const prevStatus = state.status;
state.status = status;
state.lastStatusChangeAt = Date.now();
if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') {
if (state.lastUserMessageAt && state.viewedByClients.size === 0) {
state.needsAttention = true;
}
}
};
const updateSessionState = (sessionId, status, eventId, metadata = {}) => {
if (!sessionId || typeof sessionId !== 'string') return;
const now = Date.now();
const existing = sessionStates.get(sessionId);
const existingAttentionState = sessionAttentionStates.get(sessionId);
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) {
return;
}
sessionStates.set(sessionId, {
status,
lastUpdateAt: now,
lastEventId: eventId || `server-${now}`,
metadata: { ...existing?.metadata, ...metadata },
});
updateSessionAttentionStatus(sessionId, status);
const attentionState = sessionAttentionStates.get(sessionId);
const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention;
const clients = getNotificationClients();
if (!existing || existing.status !== status || attentionChanged) {
const state = sessionStates.get(sessionId);
const syntheticPayload = {
type: 'openchamber:session-status',
properties: {
sessionID: sessionId,
status: state.status,
timestamp: state.lastUpdateAt,
metadata: state.metadata,
needsAttention: attentionState?.needsAttention ?? false,
},
};
if (typeof broadcastEvent === 'function') {
broadcastEvent(syntheticPayload);
} else if (clients.size > 0) {
for (const res of clients) {
try {
writeSseEvent(res, syntheticPayload);
} catch {
}
}
}
}
const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle';
if (phase !== 'idle' || sessionActivityPhases.get(sessionId)?.phase !== 'cooldown') {
setSessionActivityPhase(sessionId, phase);
}
};
const getSessionStateSnapshot = () => {
const result = {};
const now = Date.now();
for (const [sessionId, data] of sessionStates) {
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue;
result[sessionId] = {
status: data.status,
lastUpdateAt: data.lastUpdateAt,
metadata: data.metadata,
};
}
return result;
};
const getSessionState = (sessionId) => {
if (!sessionId) return null;
return sessionStates.get(sessionId) || null;
};
const markSessionViewed = (sessionId, clientId) => {
const state = getOrCreateAttentionState(sessionId);
if (!state) return;
const wasNeedsAttention = state.needsAttention;
state.viewedByClients.add(clientId);
if (wasNeedsAttention) {
state.needsAttention = false;
const syntheticPayload = {
type: 'openchamber:session-status',
properties: {
sessionID: sessionId,
status: state.status,
timestamp: Date.now(),
metadata: {},
needsAttention: false,
},
};
if (typeof broadcastEvent === 'function') {
broadcastEvent(syntheticPayload);
} else {
const clients = getNotificationClients();
for (const res of clients) {
try {
writeSseEvent(res, syntheticPayload);
} catch {
}
}
}
}
};
const markSessionUnviewed = (sessionId, clientId) => {
const state = sessionAttentionStates.get(sessionId);
if (!state) return;
state.viewedByClients.delete(clientId);
};
const markUserMessageSent = (sessionId) => {
const state = getOrCreateAttentionState(sessionId);
if (!state) return;
state.lastUserMessageAt = Date.now();
};
const getSessionAttentionSnapshot = () => {
const result = {};
const now = Date.now();
for (const [sessionId, state] of sessionAttentionStates) {
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue;
result[sessionId] = {
needsAttention: state.needsAttention,
lastUserMessageAt: state.lastUserMessageAt,
lastStatusChangeAt: state.lastStatusChangeAt,
status: state.status,
isViewed: state.viewedByClients.size > 0,
};
}
return result;
};
const getSessionAttentionState = (sessionId) => {
if (!sessionId) return null;
const state = sessionAttentionStates.get(sessionId);
if (!state) return null;
return {
needsAttention: state.needsAttention,
lastUserMessageAt: state.lastUserMessageAt,
lastStatusChangeAt: state.lastStatusChangeAt,
status: state.status,
isViewed: state.viewedByClients.size > 0,
};
};
const getSessionActivitySnapshot = () => {
const result = {};
for (const [sessionId, data] of sessionActivityPhases) {
result[sessionId] = { type: data.phase };
}
return result;
};
const resetAllSessionActivityToIdle = () => {
for (const timer of sessionActivityCooldowns.values()) {
clearTimeout(timer);
}
sessionActivityCooldowns.clear();
const now = Date.now();
for (const [sessionId] of sessionActivityPhases) {
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now });
}
};
const cleanupOldSessionStates = () => {
const now = Date.now();
for (const [sessionId, data] of sessionStates) {
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) {
sessionStates.delete(sessionId);
}
}
for (const [sessionId, state] of sessionAttentionStates) {
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) {
sessionAttentionStates.delete(sessionId);
}
}
};
const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS);
const processOpenCodeSsePayload = (payload) => {
const transitions = deriveSessionActivityTransitions(payload);
for (const activity of transitions) {
setSessionActivityPhase(activity.sessionId, activity.phase);
}
if (payload && payload.type === 'session.status') {
const update = extractSessionStatusUpdate(payload);
if (update) {
updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, {
attempt: update.attempt,
message: update.message,
next: update.next,
});
}
}
};
const dispose = () => {
clearInterval(cleanupInterval);
for (const timer of sessionActivityCooldowns.values()) {
clearTimeout(timer);
}
sessionActivityCooldowns.clear();
};
return {
processOpenCodeSsePayload,
getSessionActivitySnapshot,
getSessionStateSnapshot,
getSessionAttentionSnapshot,
getSessionState,
getSessionAttentionState,
markSessionViewed,
markSessionUnviewed,
markUserMessageSent,
resetAllSessionActivityToIdle,
dispose,
};
};