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:
parent
5836fb7d80
commit
5e1fd62ed9
13 changed files with 855 additions and 665 deletions
|
|
@ -76,6 +76,21 @@ export function createFilesHandlers() {
|
|||
}
|
||||
},
|
||||
|
||||
// Feature 2: Get file stat (mtime) for conflict detection
|
||||
"files.stat": async ({ path: filePath }: { path: string }) => {
|
||||
const guard = guardPath(filePath);
|
||||
if (!guard.valid) {
|
||||
return { mtimeMs: 0, size: 0, error: guard.error };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(guard.resolved);
|
||||
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { mtimeMs: 0, size: 0, error };
|
||||
}
|
||||
},
|
||||
|
||||
"files.write": async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||
const guard = guardPath(filePath);
|
||||
if (!guard.valid) {
|
||||
|
|
@ -83,7 +98,10 @@ export function createFilesHandlers() {
|
|||
return { ok: false, error: guard.error };
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(guard.resolved, content, "utf8");
|
||||
// Feature 2: Atomic write via temp file + rename
|
||||
const tmpPath = guard.resolved + ".agor-tmp";
|
||||
fs.writeFileSync(tmpPath, content, "utf8");
|
||||
fs.renameSync(tmpPath, guard.resolved);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@
|
|||
let editorContent = $state('');
|
||||
// Fix #6: Request token to discard stale file load responses
|
||||
let fileRequestToken = 0;
|
||||
// Feature 2: Track mtime at read time for conflict detection
|
||||
let readMtimeMs = $state(0);
|
||||
let showConflictDialog = $state(false);
|
||||
|
||||
// Extension-based type detection
|
||||
const CODE_EXTS = new Set([
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -128,7 +128,12 @@ export type PtyRPCRequests = {
|
|||
error?: string;
|
||||
};
|
||||
};
|
||||
/** Write text content to a file. */
|
||||
/** Get file stat info (mtime, size) for conflict detection. */
|
||||
"files.stat": {
|
||||
params: { path: string };
|
||||
response: { mtimeMs: number; size: number; error?: string };
|
||||
};
|
||||
/** Write text content to a file (atomic temp+rename). */
|
||||
"files.write": {
|
||||
params: { path: string; content: string };
|
||||
response: { ok: boolean; error?: string };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue