diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts index abce596..a8128d5 100644 --- a/ui-electrobun/src/mainview/agent-store.svelte.ts +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -121,6 +121,8 @@ const cleanupTimers = new Map>(); const msgPersistTimers = new Map>(); // Fix #12: Track last persisted index per session to avoid re-saving entire history const lastPersistedIndex = new Map(); +// Fix #2 (Codex audit): Guard against double-start race +const startingProjects = new Set(); // ── Session persistence helpers ───────────────────────────────────────────── @@ -153,6 +155,8 @@ function persistMessages(session: AgentSession): void { const startIdx = lastPersistedIndex.get(session.sessionId) ?? 0; const newMsgs = session.messages.slice(startIdx); if (newMsgs.length === 0) return; + // Fix #1 (Codex audit): Snapshot batch end BEFORE async save to avoid race + const batchEnd = session.messages.length; const msgs = newMsgs.map((m) => ({ sessionId: session.sessionId, msgId: m.id, @@ -163,7 +167,7 @@ function persistMessages(session: AgentSession): void { timestamp: m.timestamp, })); appRpc.request['session.messages.save']({ messages: msgs }).then(() => { - lastPersistedIndex.set(session.sessionId, session.messages.length); + lastPersistedIndex.set(session.sessionId, batchEnd); }).catch((err: unknown) => { console.error('[session.messages.save] persist error:', err); }); @@ -434,6 +438,25 @@ export async function startAgent( ): Promise<{ ok: boolean; error?: string }> { ensureListeners(); + // Fix #2 (Codex audit): Prevent double-start race + if (startingProjects.has(projectId)) { + return { ok: false, error: 'Session start already in progress' }; + } + startingProjects.add(projectId); + + try { + return await _startAgentInner(projectId, provider, prompt, options); + } finally { + startingProjects.delete(projectId); + } +} + +async function _startAgentInner( + projectId: string, + provider: string, + prompt: string, + options: StartOptions, +): Promise<{ ok: boolean; error?: string }> { // If there's an existing done/error session for this project, clear it first clearSession(projectId); @@ -583,6 +606,16 @@ export function clearSession(projectId: string): void { */ export async function loadLastSession(projectId: string): Promise { ensureListeners(); + + // Fix #5 (Codex audit): Don't overwrite an active (running/starting) session + const existingSessionId = projectSessionMap.get(projectId); + if (existingSessionId) { + const existing = sessions[existingSessionId]; + if (existing && (existing.status === 'running' || startingProjects.has(projectId))) { + return false; + } + } + try { const { session } = await appRpc.request['session.load']({ projectId }); if (!session) return false; @@ -628,5 +661,65 @@ export async function loadLastSession(projectId: string): Promise { } } +// ── Fix #14 (Codex audit): Session memory management ───────────────────────── + +const MAX_SESSIONS_PER_PROJECT = 5; + +/** + * Purge a session entirely from the sessions map. + * Call when a project is deleted or to free memory. + */ +export function purgeSession(sessionId: string): void { + delete sessions[sessionId]; + lastPersistedIndex.delete(sessionId); + clearStallTimer(sessionId); + const pendingTimer = msgPersistTimers.get(sessionId); + if (pendingTimer) { + clearTimeout(pendingTimer); + msgPersistTimers.delete(sessionId); + } + const cleanupTimer = cleanupTimers.get(sessionId); + if (cleanupTimer) { + clearTimeout(cleanupTimer); + cleanupTimers.delete(sessionId); + } +} + +/** + * Purge all sessions for a project. + * Call when a project is removed from the workspace. + */ +export function purgeProjectSessions(projectId: string): void { + const sessionId = projectSessionMap.get(projectId); + if (sessionId) { + purgeSession(sessionId); + projectSessionMap.delete(projectId); + } + // Also purge any orphaned sessions for this project + for (const [sid, session] of Object.entries(sessions)) { + if (session.projectId === projectId) { + purgeSession(sid); + } + } +} + +/** Enforce max sessions per project — keep only the most recent N. */ +function enforceMaxSessions(projectId: string): void { + const projectSessions = Object.entries(sessions) + .filter(([, s]) => s.projectId === projectId && s.status !== 'running') + .sort(([, a], [, b]) => { + const aTs = a.messages[a.messages.length - 1]?.timestamp ?? 0; + const bTs = b.messages[b.messages.length - 1]?.timestamp ?? 0; + return bTs - aTs; // newest first + }); + + if (projectSessions.length > MAX_SESSIONS_PER_PROJECT) { + const toRemove = projectSessions.slice(MAX_SESSIONS_PER_PROJECT); + for (const [sid] of toRemove) { + purgeSession(sid); + } + } +} + /** Initialize listeners on module load. */ ensureListeners();