- packages/stores/: theme, notifications, health stores extracted - Electrobun hardening: durable event sequencing, file conflict detection, push-based updates, backpressure guards (partial, agents still running)
799 lines
26 KiB
TypeScript
799 lines
26 KiB
TypeScript
/**
|
|
* Agent session store — manages per-project agent state and RPC communication.
|
|
*
|
|
* Listens for agent.message, agent.status, agent.cost events from Bun process.
|
|
* Exposes reactive Svelte 5 rune state per project.
|
|
*/
|
|
|
|
import { appRpc } from './rpc.ts';
|
|
import { recordActivity, recordToolDone, recordTokenSnapshot, setProjectStatus } from './health-store.svelte.ts';
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export type AgentStatus = 'idle' | 'running' | 'done' | 'error';
|
|
export type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system';
|
|
|
|
export interface AgentMessage {
|
|
id: string;
|
|
seqId: number;
|
|
role: MsgRole;
|
|
content: string;
|
|
toolName?: string;
|
|
toolInput?: string;
|
|
toolPath?: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface AgentSession {
|
|
sessionId: string;
|
|
projectId: string;
|
|
provider: string;
|
|
status: AgentStatus;
|
|
messages: AgentMessage[];
|
|
costUsd: number;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
model: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface StartOptions {
|
|
cwd?: string;
|
|
model?: string;
|
|
systemPrompt?: string;
|
|
maxTurns?: number;
|
|
permissionMode?: string;
|
|
claudeConfigDir?: string;
|
|
extraEnv?: Record<string, string>;
|
|
additionalDirectories?: string[];
|
|
worktreeName?: string;
|
|
}
|
|
|
|
// ── Toast callback (set by App.svelte) ────────────────────────────────────────
|
|
|
|
type ToastFn = (message: string, variant: 'success' | 'warning' | 'error' | 'info') => void;
|
|
let _toastFn: ToastFn | null = null;
|
|
|
|
/** Register a toast callback for agent notifications. */
|
|
export function setAgentToastFn(fn: ToastFn): void { _toastFn = fn; }
|
|
|
|
function emitToast(message: string, variant: 'success' | 'warning' | 'error' | 'info') {
|
|
_toastFn?.(message, variant);
|
|
}
|
|
|
|
// ── Stall detection ───────────────────────────────────────────────────────────
|
|
|
|
const stallTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
const DEFAULT_STALL_MS = 15 * 60 * 1000; // 15 minutes
|
|
|
|
function resetStallTimer(sessionId: string, projectId: string): void {
|
|
const existing = stallTimers.get(sessionId);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
const timer = setTimeout(() => {
|
|
stallTimers.delete(sessionId);
|
|
const session = sessions[sessionId];
|
|
if (session && session.status === 'running') {
|
|
emitToast(`Agent stalled on ${projectId} (no activity for 15 min)`, 'warning');
|
|
}
|
|
}, DEFAULT_STALL_MS);
|
|
|
|
stallTimers.set(sessionId, timer);
|
|
}
|
|
|
|
function clearStallTimer(sessionId: string): void {
|
|
const timer = stallTimers.get(sessionId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
stallTimers.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
// ── Env var validation (Fix #14) ─────────────────────────────────────────────
|
|
|
|
const BLOCKED_ENV_PREFIXES = ['CLAUDE', 'CODEX', 'OLLAMA', 'ANTHROPIC_'];
|
|
|
|
function validateExtraEnv(env: Record<string, string> | undefined): Record<string, string> | undefined {
|
|
if (!env) return undefined;
|
|
const clean: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(env)) {
|
|
const blocked = BLOCKED_ENV_PREFIXES.some(p => key.startsWith(p));
|
|
if (blocked) {
|
|
console.warn(`[agent-store] Rejected extraEnv key "${key}" — provider-prefixed keys are not allowed`);
|
|
continue;
|
|
}
|
|
clean[key] = value;
|
|
}
|
|
return Object.keys(clean).length > 0 ? clean : undefined;
|
|
}
|
|
|
|
// ── Internal state ───────────────────────────────────────────────────────────
|
|
|
|
// Map projectId -> sessionId for lookup
|
|
const projectSessionMap = new Map<string, string>();
|
|
|
|
// Map sessionId -> reactive session state
|
|
let sessions = $state<Record<string, AgentSession>>({});
|
|
|
|
// Grace period timers for cleanup after done/error
|
|
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
|
// Debounce timer for message persistence
|
|
const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
// Fix #12: Track last persisted index per session to avoid re-saving entire history
|
|
const lastPersistedIndex = new Map<string, number>();
|
|
// Fix #2 (Codex audit): Guard against double-start race
|
|
const startingProjects = new Set<string>();
|
|
// Feature 1: Monotonic seqId counter per session for dedup on restore
|
|
const seqCounters = new Map<string, number>();
|
|
|
|
function nextSeqId(sessionId: string): number {
|
|
const current = seqCounters.get(sessionId) ?? 0;
|
|
const next = current + 1;
|
|
seqCounters.set(sessionId, next);
|
|
return next;
|
|
}
|
|
|
|
// ── Session persistence helpers ─────────────────────────────────────────────
|
|
|
|
function persistSession(session: AgentSession): void {
|
|
appRpc.request['session.save']({
|
|
projectId: session.projectId,
|
|
sessionId: session.sessionId,
|
|
provider: session.provider,
|
|
status: session.status,
|
|
costUsd: session.costUsd,
|
|
inputTokens: session.inputTokens,
|
|
outputTokens: session.outputTokens,
|
|
model: session.model,
|
|
error: session.error,
|
|
createdAt: session.messages[0]?.timestamp ?? Date.now(),
|
|
updatedAt: Date.now(),
|
|
}).catch((err: unknown) => {
|
|
console.error('[session.save] persist error:', err);
|
|
});
|
|
}
|
|
|
|
function persistMessages(session: AgentSession): void {
|
|
// Debounce: batch message saves every 2 seconds
|
|
const existing = msgPersistTimers.get(session.sessionId);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
const timer = setTimeout(() => {
|
|
msgPersistTimers.delete(session.sessionId);
|
|
// Fix #12: Only persist NEW messages (from lastPersistedIndex onward)
|
|
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,
|
|
role: m.role,
|
|
content: m.content,
|
|
toolName: m.toolName,
|
|
toolInput: m.toolInput,
|
|
timestamp: m.timestamp,
|
|
seqId: m.seqId,
|
|
}));
|
|
appRpc.request['session.messages.save']({ messages: msgs }).then(() => {
|
|
lastPersistedIndex.set(session.sessionId, batchEnd);
|
|
}).catch((err: unknown) => {
|
|
console.error('[session.messages.save] persist error:', err);
|
|
});
|
|
}, 2000);
|
|
|
|
msgPersistTimers.set(session.sessionId, timer);
|
|
}
|
|
|
|
// ── RPC event listeners (registered once) ────────────────────────────────────
|
|
|
|
let listenersRegistered = false;
|
|
|
|
function ensureListeners() {
|
|
if (listenersRegistered) return;
|
|
listenersRegistered = true;
|
|
|
|
// agent.message — raw messages from sidecar, converted to display format
|
|
appRpc.addMessageListener('agent.message', (payload: {
|
|
sessionId: string;
|
|
messages: Array<{
|
|
id: string;
|
|
type: string;
|
|
parentId?: string;
|
|
content: unknown;
|
|
timestamp: number;
|
|
}>;
|
|
}) => {
|
|
const session = sessions[payload.sessionId];
|
|
if (!session) return;
|
|
|
|
const converted: AgentMessage[] = [];
|
|
for (const raw of payload.messages) {
|
|
const msg = convertRawMessage(raw);
|
|
if (msg) converted.push(msg);
|
|
}
|
|
|
|
if (converted.length > 0) {
|
|
// Feature 1: Assign monotonic seqId to each message for dedup
|
|
for (const msg of converted) {
|
|
msg.seqId = nextSeqId(payload.sessionId);
|
|
}
|
|
session.messages = [...session.messages, ...converted];
|
|
persistMessages(session);
|
|
// Reset stall timer on activity
|
|
resetStallTimer(payload.sessionId, session.projectId);
|
|
// Fix #14: Wire health store — record activity on every message batch
|
|
for (const msg of converted) {
|
|
if (msg.role === 'tool-call') {
|
|
recordActivity(session.projectId, msg.toolName);
|
|
} else if (msg.role === 'tool-result') {
|
|
recordToolDone(session.projectId);
|
|
} else {
|
|
recordActivity(session.projectId);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// agent.status — session status changes
|
|
appRpc.addMessageListener('agent.status', (payload: {
|
|
sessionId: string;
|
|
status: string;
|
|
error?: string;
|
|
}) => {
|
|
const session = sessions[payload.sessionId];
|
|
if (!session) return;
|
|
|
|
session.status = normalizeStatus(payload.status);
|
|
if (payload.error) session.error = payload.error;
|
|
|
|
// Fix #14: Wire health store — update project status
|
|
setProjectStatus(session.projectId, session.status === 'done' ? 'done' : session.status === 'error' ? 'error' : session.status === 'running' ? 'running' : 'idle');
|
|
|
|
// Persist on every status change
|
|
persistSession(session);
|
|
|
|
// Emit toast notification on completion
|
|
if (session.status === 'done') {
|
|
clearStallTimer(payload.sessionId);
|
|
emitToast(`Agent completed on ${session.projectId}`, 'success');
|
|
} else if (session.status === 'error') {
|
|
clearStallTimer(payload.sessionId);
|
|
emitToast(`Agent error on ${session.projectId}: ${payload.error ?? 'unknown'}`, 'error');
|
|
}
|
|
|
|
// Schedule cleanup after done/error (Fix #2)
|
|
if (session.status === 'done' || session.status === 'error') {
|
|
// Flush any pending message persistence immediately
|
|
const pendingTimer = msgPersistTimers.get(session.sessionId);
|
|
if (pendingTimer) {
|
|
clearTimeout(pendingTimer);
|
|
msgPersistTimers.delete(session.sessionId);
|
|
}
|
|
persistMessages(session);
|
|
scheduleCleanup(session.sessionId, session.projectId);
|
|
// Fix #14 (Codex audit): Enforce max sessions per project on completion
|
|
enforceMaxSessions(session.projectId);
|
|
}
|
|
});
|
|
|
|
// agent.cost — token/cost updates
|
|
appRpc.addMessageListener('agent.cost', (payload: {
|
|
sessionId: string;
|
|
costUsd: number;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
}) => {
|
|
const session = sessions[payload.sessionId];
|
|
if (!session) return;
|
|
|
|
session.costUsd = payload.costUsd;
|
|
session.inputTokens = payload.inputTokens;
|
|
session.outputTokens = payload.outputTokens;
|
|
// Fix #14: Wire health store — record token/cost snapshot
|
|
recordTokenSnapshot(session.projectId, payload.inputTokens + payload.outputTokens, payload.costUsd);
|
|
});
|
|
}
|
|
|
|
// ── Cleanup scheduling (Fix #2) ──────────────────────────────────────────────
|
|
|
|
const CLEANUP_GRACE_MS = 60_000; // 60 seconds after done/error
|
|
|
|
function scheduleCleanup(sessionId: string, projectId: string) {
|
|
// Cancel any existing timer for this session
|
|
const existing = cleanupTimers.get(sessionId);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
const timer = setTimeout(() => {
|
|
cleanupTimers.delete(sessionId);
|
|
// Only clean up if session is still in done/error state
|
|
const session = sessions[sessionId];
|
|
if (session && (session.status === 'done' || session.status === 'error')) {
|
|
// Keep session data (messages, cost) but remove from projectSessionMap
|
|
// so starting a new session on this project works cleanly
|
|
const currentMapped = projectSessionMap.get(projectId);
|
|
if (currentMapped === sessionId) {
|
|
projectSessionMap.delete(projectId);
|
|
}
|
|
}
|
|
}, CLEANUP_GRACE_MS);
|
|
|
|
cleanupTimers.set(sessionId, timer);
|
|
}
|
|
|
|
// ── Message conversion ───────────────────────────────────────────────────────
|
|
|
|
function convertRawMessage(raw: {
|
|
id: string;
|
|
type: string;
|
|
parentId?: string;
|
|
content: unknown;
|
|
timestamp: number;
|
|
}): AgentMessage | null {
|
|
const c = raw.content as Record<string, unknown> | undefined;
|
|
|
|
switch (raw.type) {
|
|
case 'text':
|
|
return {
|
|
id: raw.id,
|
|
seqId: 0,
|
|
role: 'assistant',
|
|
content: String(c?.text ?? ''),
|
|
timestamp: raw.timestamp,
|
|
};
|
|
|
|
case 'thinking':
|
|
return {
|
|
id: raw.id,
|
|
seqId: 0,
|
|
role: 'thinking',
|
|
content: String(c?.text ?? ''),
|
|
timestamp: raw.timestamp,
|
|
};
|
|
|
|
case 'tool_call': {
|
|
const name = String(c?.name ?? 'Tool');
|
|
const input = c?.input as Record<string, unknown> | undefined;
|
|
const path = extractToolPath(name, input);
|
|
return {
|
|
id: raw.id,
|
|
seqId: 0,
|
|
role: 'tool-call',
|
|
content: formatToolInput(name, input),
|
|
toolName: name,
|
|
toolInput: JSON.stringify(input, null, 2),
|
|
toolPath: path,
|
|
timestamp: raw.timestamp,
|
|
};
|
|
}
|
|
|
|
case 'tool_result': {
|
|
const output = c?.output;
|
|
const text = typeof output === 'string'
|
|
? output
|
|
: JSON.stringify(output, null, 2);
|
|
return {
|
|
id: raw.id,
|
|
seqId: 0,
|
|
role: 'tool-result',
|
|
content: truncateOutput(text, 500),
|
|
timestamp: raw.timestamp,
|
|
};
|
|
}
|
|
|
|
case 'init': {
|
|
const model = String(c?.model ?? '');
|
|
const sid = String(c?.sessionId ?? '');
|
|
for (const s of Object.values(sessions)) {
|
|
if (s.sessionId === raw.id || (sid && s.sessionId.includes(sid.slice(0, 8)))) {
|
|
if (model) s.model = model;
|
|
}
|
|
}
|
|
return {
|
|
id: raw.id,
|
|
seqId: 0,
|
|
role: 'system',
|
|
content: `Session initialized${model ? ` (${model})` : ''}`,
|
|
timestamp: raw.timestamp,
|
|
};
|
|
}
|
|
|
|
case 'error':
|
|
return {
|
|
id: raw.id,
|
|
seqId: 0,
|
|
role: 'system',
|
|
content: `Error: ${String(c?.message ?? 'Unknown error')}`,
|
|
timestamp: raw.timestamp,
|
|
};
|
|
|
|
case 'cost':
|
|
case 'status':
|
|
case 'compaction':
|
|
case 'unknown':
|
|
return null;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractToolPath(name: string, input: Record<string, unknown> | undefined): string | undefined {
|
|
if (!input) return undefined;
|
|
if (typeof input.file_path === 'string') return input.file_path;
|
|
if (typeof input.path === 'string') return input.path;
|
|
if (name === 'Bash' && typeof input.command === 'string') {
|
|
return input.command.length > 80 ? input.command.slice(0, 80) + '...' : input.command;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatToolInput(name: string, input: Record<string, unknown> | undefined): string {
|
|
if (!input) return '';
|
|
if (name === 'Bash' && typeof input.command === 'string') return input.command;
|
|
if (typeof input.file_path === 'string') return input.file_path;
|
|
return JSON.stringify(input, null, 2);
|
|
}
|
|
|
|
function truncateOutput(text: string, maxLines: number): string {
|
|
const lines = text.split('\n');
|
|
if (lines.length <= maxLines) return text;
|
|
return lines.slice(0, maxLines).join('\n') + `\n... (${lines.length - maxLines} more lines)`;
|
|
}
|
|
|
|
function normalizeStatus(status: string): AgentStatus {
|
|
if (status === 'running' || status === 'idle' || status === 'done' || status === 'error') {
|
|
return status;
|
|
}
|
|
return 'idle';
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
/** Start an agent session for a project (Fix #5: reads permission_mode + system_prompt from settings). */
|
|
export async function startAgent(
|
|
projectId: string,
|
|
provider: string,
|
|
prompt: string,
|
|
options: StartOptions = {},
|
|
): 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);
|
|
|
|
const sessionId = `${projectId}-${Date.now()}`;
|
|
|
|
// Read settings defaults if not explicitly provided (Fix #5)
|
|
let permissionMode = options.permissionMode;
|
|
let systemPrompt = options.systemPrompt;
|
|
let defaultModel = options.model;
|
|
let cwd = options.cwd;
|
|
try {
|
|
const { settings } = await appRpc.request['settings.getAll']({});
|
|
if (!permissionMode && settings['permission_mode']) {
|
|
permissionMode = settings['permission_mode'];
|
|
}
|
|
if (!systemPrompt && settings['system_prompt_template']) {
|
|
systemPrompt = settings['system_prompt_template'];
|
|
}
|
|
if (!cwd && settings['default_cwd']) {
|
|
cwd = settings['default_cwd'];
|
|
}
|
|
// Read default model from provider_settings if not specified
|
|
if (!defaultModel && settings['provider_settings']) {
|
|
try {
|
|
const providerSettings = JSON.parse(settings['provider_settings']);
|
|
const provConfig = providerSettings[provider];
|
|
if (provConfig?.defaultModel) defaultModel = provConfig.defaultModel;
|
|
} catch { /* ignore parse errors */ }
|
|
}
|
|
} catch { /* use provided or defaults */ }
|
|
|
|
// Create reactive session state
|
|
sessions[sessionId] = {
|
|
sessionId,
|
|
projectId,
|
|
provider,
|
|
status: 'running',
|
|
messages: [{
|
|
id: `${sessionId}-user-0`,
|
|
seqId: nextSeqId(sessionId),
|
|
role: 'user',
|
|
content: prompt,
|
|
timestamp: Date.now(),
|
|
}],
|
|
costUsd: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
model: defaultModel ?? 'claude-opus-4-5',
|
|
};
|
|
|
|
projectSessionMap.set(projectId, sessionId);
|
|
resetStallTimer(sessionId, projectId);
|
|
|
|
const result = await appRpc.request['agent.start']({
|
|
sessionId,
|
|
provider: provider as 'claude' | 'codex' | 'ollama',
|
|
prompt,
|
|
cwd,
|
|
model: defaultModel,
|
|
systemPrompt: systemPrompt,
|
|
maxTurns: options.maxTurns,
|
|
permissionMode: permissionMode,
|
|
claudeConfigDir: options.claudeConfigDir,
|
|
extraEnv: validateExtraEnv(options.extraEnv),
|
|
});
|
|
|
|
if (!result.ok) {
|
|
sessions[sessionId].status = 'error';
|
|
sessions[sessionId].error = result.error;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Stop a running agent session for a project. */
|
|
export async function stopAgent(projectId: string): Promise<{ ok: boolean; error?: string }> {
|
|
const sessionId = projectSessionMap.get(projectId);
|
|
if (!sessionId) return { ok: false, error: 'No session for project' };
|
|
|
|
const result = await appRpc.request['agent.stop']({ sessionId });
|
|
|
|
if (result.ok) {
|
|
const session = sessions[sessionId];
|
|
if (session) session.status = 'done';
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Send a follow-up prompt to a running session. */
|
|
export async function sendPrompt(projectId: string, prompt: string): Promise<{ ok: boolean; error?: string }> {
|
|
const sessionId = projectSessionMap.get(projectId);
|
|
if (!sessionId) return { ok: false, error: 'No session for project' };
|
|
|
|
const session = sessions[sessionId];
|
|
if (!session) return { ok: false, error: 'Session not found' };
|
|
|
|
// Add user message immediately
|
|
session.messages = [...session.messages, {
|
|
id: `${sessionId}-user-${Date.now()}`,
|
|
seqId: nextSeqId(sessionId),
|
|
role: 'user',
|
|
content: prompt,
|
|
timestamp: Date.now(),
|
|
}];
|
|
|
|
session.status = 'running';
|
|
|
|
return appRpc.request['agent.prompt']({ sessionId, prompt });
|
|
}
|
|
|
|
/** Get the current session for a project (reactive). */
|
|
export function getSession(projectId: string): AgentSession | undefined {
|
|
const sessionId = projectSessionMap.get(projectId);
|
|
if (!sessionId) return undefined;
|
|
return sessions[sessionId];
|
|
}
|
|
|
|
/** Check if a project has an active session. */
|
|
export function hasSession(projectId: string): boolean {
|
|
return projectSessionMap.has(projectId);
|
|
}
|
|
|
|
/**
|
|
* Clear a done/error session for a project (Fix #2).
|
|
* Removes from projectSessionMap so a new session can start.
|
|
* Keeps session data in sessions map for history access.
|
|
*/
|
|
export function clearSession(projectId: string): void {
|
|
const sessionId = projectSessionMap.get(projectId);
|
|
if (!sessionId) return;
|
|
|
|
const session = sessions[sessionId];
|
|
if (session && (session.status === 'done' || session.status === 'error')) {
|
|
projectSessionMap.delete(projectId);
|
|
// Cancel any pending cleanup timer
|
|
const timer = cleanupTimers.get(sessionId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
cleanupTimers.delete(sessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load the last session for a project from SQLite (for restart recovery).
|
|
* Restores session state + messages into the reactive store.
|
|
* Only restores done/error sessions (running sessions are gone after restart).
|
|
*/
|
|
export async function loadLastSession(projectId: string): Promise<boolean> {
|
|
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;
|
|
|
|
// Only restore completed sessions (running sessions can't be resumed)
|
|
if (session.status !== 'done' && session.status !== 'error') return false;
|
|
|
|
// Load messages for this session
|
|
const { messages: storedMsgs } = await appRpc.request['session.messages.load']({
|
|
sessionId: session.sessionId,
|
|
});
|
|
|
|
// Feature 1: Deduplicate by seqId and resume counter from max
|
|
const seqIdSet = new Set<number>();
|
|
const restoredMessages: AgentMessage[] = [];
|
|
let maxSeqId = 0;
|
|
for (const m of storedMsgs as Array<{
|
|
msgId: string; role: string; content: string;
|
|
toolName?: string; toolInput?: string; timestamp: number;
|
|
seqId?: number;
|
|
}>) {
|
|
const sid = m.seqId ?? 0;
|
|
if (sid > 0 && seqIdSet.has(sid)) continue; // deduplicate
|
|
if (sid > 0) seqIdSet.add(sid);
|
|
if (sid > maxSeqId) maxSeqId = sid;
|
|
restoredMessages.push({
|
|
id: m.msgId,
|
|
seqId: sid,
|
|
role: m.role as MsgRole,
|
|
content: m.content,
|
|
toolName: m.toolName,
|
|
toolInput: m.toolInput,
|
|
timestamp: m.timestamp,
|
|
});
|
|
}
|
|
// Resume seqId counter from max
|
|
if (maxSeqId > 0) seqCounters.set(session.sessionId, maxSeqId);
|
|
|
|
sessions[session.sessionId] = {
|
|
sessionId: session.sessionId,
|
|
projectId: session.projectId,
|
|
provider: session.provider,
|
|
status: normalizeStatus(session.status),
|
|
messages: restoredMessages,
|
|
costUsd: session.costUsd,
|
|
inputTokens: session.inputTokens,
|
|
outputTokens: session.outputTokens,
|
|
model: session.model,
|
|
error: session.error,
|
|
};
|
|
|
|
projectSessionMap.set(projectId, session.sessionId);
|
|
return true;
|
|
} catch (err) {
|
|
console.error('[loadLastSession] error:', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Fix #14 (Codex audit): Session memory management ─────────────────────────
|
|
|
|
// Feature 6: Configurable retention — defaults, overridable via settings
|
|
let retentionCount = 5;
|
|
let retentionDays = 30;
|
|
|
|
/** Update retention settings (called from ProjectSettings). */
|
|
export function setRetentionConfig(count: number, days: number): void {
|
|
retentionCount = Math.max(1, Math.min(50, count));
|
|
retentionDays = Math.max(1, Math.min(365, days));
|
|
}
|
|
|
|
/** Load retention settings from backend on startup. */
|
|
export async function loadRetentionConfig(): Promise<void> {
|
|
try {
|
|
const { settings } = await appRpc.request['settings.getAll']({});
|
|
if (settings['session_retention_count']) {
|
|
retentionCount = Math.max(1, parseInt(settings['session_retention_count'], 10) || 5);
|
|
}
|
|
if (settings['session_retention_days']) {
|
|
retentionDays = Math.max(1, parseInt(settings['session_retention_days'], 10) || 30);
|
|
}
|
|
} catch { /* use defaults */ }
|
|
}
|
|
|
|
const MAX_SESSIONS_PER_PROJECT = 5; // legacy fallback
|
|
|
|
/**
|
|
* 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);
|
|
seqCounters.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 + prune by age. */
|
|
function enforceMaxSessions(projectId: string): void {
|
|
const now = Date.now();
|
|
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
|
|
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
|
|
});
|
|
|
|
// Feature 6: Prune by retention count
|
|
if (projectSessions.length > retentionCount) {
|
|
const toRemove = projectSessions.slice(retentionCount);
|
|
for (const [sid] of toRemove) {
|
|
purgeSession(sid);
|
|
}
|
|
}
|
|
|
|
// Feature 6: Prune by age (retention days)
|
|
for (const [sid, s] of projectSessions) {
|
|
const lastTs = s.messages[s.messages.length - 1]?.timestamp ?? 0;
|
|
if (lastTs > 0 && (now - lastTs) > maxAgeMs) {
|
|
purgeSession(sid);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Initialize listeners on module load. */
|
|
ensureListeners();
|