feat: @agor/stores package + Electrobun hardening (WIP)

- packages/stores/: theme, notifications, health stores extracted
- Electrobun hardening: durable event sequencing, file conflict detection,
  push-based updates, backpressure guards (partial, agents still running)
This commit is contained in:
Hibryda 2026-03-22 04:40:04 +01:00
parent 5836fb7d80
commit 5e1fd62ed9
13 changed files with 855 additions and 665 deletions

View file

@ -15,6 +15,7 @@ export type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thin
export interface AgentMessage {
id: string;
seqId: number;
role: MsgRole;
content: string;
toolName?: string;
@ -123,6 +124,15 @@ const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
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 ─────────────────────────────────────────────
@ -165,6 +175,7 @@ function persistMessages(session: AgentSession): void {
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);
@ -205,6 +216,10 @@ function ensureListeners() {
}
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
@ -323,6 +338,7 @@ function convertRawMessage(raw: {
case 'text':
return {
id: raw.id,
seqId: 0,
role: 'assistant',
content: String(c?.text ?? ''),
timestamp: raw.timestamp,
@ -331,6 +347,7 @@ function convertRawMessage(raw: {
case 'thinking':
return {
id: raw.id,
seqId: 0,
role: 'thinking',
content: String(c?.text ?? ''),
timestamp: raw.timestamp,
@ -342,6 +359,7 @@ function convertRawMessage(raw: {
const path = extractToolPath(name, input);
return {
id: raw.id,
seqId: 0,
role: 'tool-call',
content: formatToolInput(name, input),
toolName: name,
@ -358,6 +376,7 @@ function convertRawMessage(raw: {
: JSON.stringify(output, null, 2);
return {
id: raw.id,
seqId: 0,
role: 'tool-result',
content: truncateOutput(text, 500),
timestamp: raw.timestamp,
@ -374,6 +393,7 @@ function convertRawMessage(raw: {
}
return {
id: raw.id,
seqId: 0,
role: 'system',
content: `Session initialized${model ? ` (${model})` : ''}`,
timestamp: raw.timestamp,
@ -383,6 +403,7 @@ function convertRawMessage(raw: {
case 'error':
return {
id: raw.id,
seqId: 0,
role: 'system',
content: `Error: ${String(c?.message ?? 'Unknown error')}`,
timestamp: raw.timestamp,
@ -498,6 +519,7 @@ async function _startAgentInner(
status: 'running',
messages: [{
id: `${sessionId}-user-0`,
seqId: nextSeqId(sessionId),
role: 'user',
content: prompt,
timestamp: Date.now(),
@ -558,6 +580,7 @@ export async function sendPrompt(projectId: string, prompt: string): Promise<{ o
// Add user message immediately
session.messages = [...session.messages, {
id: `${sessionId}-user-${Date.now()}`,
seqId: nextSeqId(sessionId),
role: 'user',
content: prompt,
timestamp: Date.now(),
@ -630,17 +653,31 @@ export async function loadLastSession(projectId: string): Promise<boolean> {
sessionId: session.sessionId,
});
const restoredMessages: AgentMessage[] = storedMsgs.map((m: {
// 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;
}) => ({
id: m.msgId,
role: m.role as MsgRole,
content: m.content,
toolName: m.toolName,
toolInput: m.toolInput,
timestamp: m.timestamp,
}));
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,
@ -665,7 +702,30 @@ export async function loadLastSession(projectId: string): Promise<boolean> {
// ── Fix #14 (Codex audit): Session memory management ─────────────────────────
const MAX_SESSIONS_PER_PROJECT = 5;
// 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.
@ -674,6 +734,7 @@ const MAX_SESSIONS_PER_PROJECT = 5;
export function purgeSession(sessionId: string): void {
delete sessions[sessionId];
lastPersistedIndex.delete(sessionId);
seqCounters.delete(sessionId);
clearStallTimer(sessionId);
const pendingTimer = msgPersistTimers.get(sessionId);
if (pendingTimer) {
@ -705,8 +766,10 @@ export function purgeProjectSessions(projectId: string): void {
}
}
/** Enforce max sessions per project — keep only the most recent N. */
/** 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]) => {
@ -715,12 +778,21 @@ function enforceMaxSessions(projectId: string): void {
return bTs - aTs; // newest first
});
if (projectSessions.length > MAX_SESSIONS_PER_PROJECT) {
const toRemove = projectSessions.slice(MAX_SESSIONS_PER_PROJECT);
// 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. */