Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
364 lines
11 KiB
JavaScript
364 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_ACTIVITY_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),
|
|
};
|
|
};
|
|
|
|
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, broadcastEvent }) => {
|
|
const sessionActivityPhases = new Map();
|
|
const sessionActivityCooldowns = new Map();
|
|
const sessionStates = new Map();
|
|
const sessionAttentionStates = new Map();
|
|
let activeSessionCount = 0;
|
|
|
|
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);
|
|
}
|
|
|
|
const wasActive = current?.phase === 'busy';
|
|
const isActive = phase === 'busy';
|
|
if (wasActive !== isActive) {
|
|
activeSessionCount = Math.max(0, activeSessionCount + (isActive ? 1 : -1));
|
|
}
|
|
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 getActiveSessionCount = () => activeSessionCount;
|
|
|
|
const resetAllSessionActivityToIdle = () => {
|
|
for (const timer of sessionActivityCooldowns.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
sessionActivityCooldowns.clear();
|
|
activeSessionCount = 0;
|
|
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);
|
|
}
|
|
}
|
|
for (const [sessionId, data] of sessionActivityPhases) {
|
|
if (now - data.updatedAt <= SESSION_ACTIVITY_MAX_AGE_MS) continue;
|
|
const timer = sessionActivityCooldowns.get(sessionId);
|
|
if (timer) clearTimeout(timer);
|
|
sessionActivityCooldowns.delete(sessionId);
|
|
sessionActivityPhases.delete(sessionId);
|
|
if (data.phase === 'busy') activeSessionCount = Math.max(0, activeSessionCount - 1);
|
|
}
|
|
};
|
|
|
|
const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS);
|
|
|
|
const processOpenCodeSsePayload = (payload) => {
|
|
const update = extractSessionStatusUpdate(payload);
|
|
if (!update) return;
|
|
|
|
if (update.type === 'busy' || update.type === 'retry') {
|
|
setSessionActivityPhase(update.sessionId, 'busy');
|
|
} else if (update.type === 'idle') {
|
|
setSessionActivityPhase(update.sessionId, 'cooldown');
|
|
}
|
|
|
|
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();
|
|
sessionActivityPhases.clear();
|
|
sessionStates.clear();
|
|
sessionAttentionStates.clear();
|
|
activeSessionCount = 0;
|
|
};
|
|
|
|
return {
|
|
processOpenCodeSsePayload,
|
|
getSessionActivitySnapshot,
|
|
getActiveSessionCount,
|
|
getSessionStateSnapshot,
|
|
getSessionAttentionSnapshot,
|
|
getSessionState,
|
|
getSessionAttentionState,
|
|
markSessionViewed,
|
|
markSessionUnviewed,
|
|
markUserMessageSent,
|
|
resetAllSessionActivityToIdle,
|
|
dispose,
|
|
};
|
|
};
|