diff --git a/dist/state-store.js b/dist/state-store.js deleted file mode 100644 index 60f2fb04..00000000 --- a/dist/state-store.js +++ /dev/null @@ -1,822 +0,0 @@ -/** - * @fileoverview Persistent JSON state storage for Codeman. - * - * Persists application state with debounced writes (500ms) to prevent excessive disk I/O. - * State is split into two files: - * - `~/.codeman/state.json` — main app state (sessions, tasks, config, global stats) - * - `~/.codeman/state-inner.json` — Ralph loop state per session (changes rapidly) - * - * Key exports: - * - `StateStore` class — singleton store with circuit breaker for save failures - * - `getStore(filePath?)` — factory/singleton accessor - * - * Key methods: `getState()`, `getSessions()`, `setSession()`, `getConfig()`, - * `setConfig()`, `getGlobalStats()`, `getAggregateStats()`, `getTokenStats()`, - * `getDailyStats()`, `getRalphState()`, `setRalphState()`, `save()`, `saveNow()` - * - * Auto-migrates legacy `~/.claudeman/` → `~/.codeman/` on first load. - * - * @dependencies types (AppState, RalphSessionState, GlobalStats, TokenStats), - * utils (Debouncer, MAX_SESSION_TOKENS) - * @consumedby session-manager, ralph-loop, web/server, respawn-controller, - * hooks-config, and most subsystems - * - * @module state-store - */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, copyFileSync } from 'node:fs'; -import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { createInitialState, createInitialRalphSessionState, createInitialGlobalStats, } from './types.js'; -import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js'; -/** Debounce delay for batching state writes (ms) */ -const SAVE_DEBOUNCE_MS = 500; -/** - * Persistent JSON state storage with debounced writes. - * - * State is automatically loaded on construction and saved with 500ms - * debouncing to batch rapid updates into single disk writes. - * - * @example - * ```typescript - * const store = new StateStore(); - * - * // Read state - * const sessions = store.getState().sessions; - * - * // Modify and save - * store.getState().sessions[id] = sessionState; - * store.save(); // Debounced - won't write immediately - * - * // Force immediate write - * store.saveNow(); - * ``` - */ -/** Maximum consecutive save failures before circuit breaker opens */ -const MAX_CONSECUTIVE_FAILURES = 3; -export class StateStore { - state; - filePath; - saveDeb = new Debouncer(SAVE_DEBOUNCE_MS); - dirty = false; - dirtySessions = new Set(); - cachedSessionJsons = new Map(); - // Inner state storage (separate from main state to reduce write frequency) - ralphStates = new Map(); - ralphStatePath; - ralphStateSaveDeb = new Debouncer(SAVE_DEBOUNCE_MS); - ralphStateDirty = false; - // Circuit breaker for save failures (prevents hammering disk on persistent errors) - consecutiveSaveFailures = 0; - circuitBreakerOpen = false; - // Guard against concurrent saveNowAsync() calls (debounce can race with in-flight write) - _saveInFlight = null; - constructor(filePath) { - // Migrate legacy data directory (~/.claudeman → ~/.codeman) - if (!filePath) { - const legacyDir = join(homedir(), '.claudeman'); - const newDir = join(homedir(), '.codeman'); - if (existsSync(legacyDir) && !existsSync(newDir)) { - console.log(`[state-store] Migrating data directory: ${legacyDir} → ${newDir}`); - renameSync(legacyDir, newDir); - } - const legacyCasesDir = join(homedir(), 'claudeman-cases'); - const newCasesDir = join(homedir(), 'codeman-cases'); - if (existsSync(legacyCasesDir) && !existsSync(newCasesDir)) { - console.log(`[state-store] Migrating cases directory: ${legacyCasesDir} → ${newCasesDir}`); - renameSync(legacyCasesDir, newCasesDir); - } - } - this.filePath = filePath || join(homedir(), '.codeman', 'state.json'); - this.ralphStatePath = this.filePath.replace('.json', '-inner.json'); - this.state = this.load(); - this.state.config.stateFilePath = this.filePath; - // Pre-populate session cache for loaded state - for (const [id, session] of Object.entries(this.state.sessions)) { - this.cachedSessionJsons.set(id, JSON.stringify(session)); - } - this.loadRalphStates(); - } - _mergeWithInitialState(parsed) { - const initial = createInitialState(); - return { - ...initial, - ...parsed, - sessions: { ...parsed.sessions }, - tasks: { ...parsed.tasks }, - ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop }, - config: { ...initial.config, ...parsed.config }, - }; - } - _resetCircuitBreaker() { - this.consecutiveSaveFailures = 0; - if (this.circuitBreakerOpen) { - console.log('[StateStore] Circuit breaker CLOSED - save succeeded'); - this.circuitBreakerOpen = false; - } - } - ensureDir() { - const dir = dirname(this.filePath); - if (!existsSync(dir)) { - // Use restrictive permissions (0o700) - owner only can read/write/traverse - // State files may contain sensitive session data - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - } - load() { - // Try main file first, then .bak fallback - for (const path of [this.filePath, this.filePath + '.bak']) { - try { - if (existsSync(path)) { - const data = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(data); - const result = this._mergeWithInitialState(parsed); - if (path !== this.filePath) { - console.warn(`[StateStore] Recovered state from backup: ${path}`); - } - return result; - } - } - catch (err) { - console.error(`Failed to load state from ${path}:`, err); - } - } - return createInitialState(); - } - /** - * Schedules a debounced save. - * Multiple calls within 500ms are batched into a single disk write. - * Uses async I/O to avoid blocking the event loop. - */ - save() { - this.dirty = true; - if (this.saveDeb.isPending) - return; // Already scheduled - this.saveDeb.schedule(() => { - this.saveNowAsync().catch((err) => { - console.error('[StateStore] Async save failed:', err); - }); - }); - } - /** - * Async version of saveNow — used by the debounced save() path. - * Uses non-blocking fs.promises to avoid blocking the event loop during - * the debounced write cycle. For synchronous shutdown flush, use saveNow(). - * - * Guards against concurrent execution: if a save is already in flight, - * waits for it to complete then re-checks dirty flag before starting another. - */ - async saveNowAsync() { - if (this._saveInFlight) { - await this._saveInFlight; - // After waiting, re-check if still dirty (the previous save may have handled it) - if (!this.dirty) - return; - } - this._saveInFlight = this._doSaveAsync(); - try { - await this._saveInFlight; - } - finally { - this._saveInFlight = null; - } - } - /** - * Assemble JSON string with incremental per-session caching. - * Only dirty sessions are re-serialized; clean sessions use cached JSON fragments. - */ - assembleStateJson() { - this.updateDirtySessionCache(); - // Build sessions object from cached fragments - const sessionParts = []; - for (const [id, session] of Object.entries(this.state.sessions)) { - let json = this.cachedSessionJsons.get(id); - if (!json) { - // Session not in cache (loaded from disk or set via direct state mutation) - json = JSON.stringify(session); - this.cachedSessionJsons.set(id, json); - } - sessionParts.push(`${JSON.stringify(id)}:${json}`); - } - this.pruneStaleCacheEntries(); - return this.buildPartialJson(sessionParts); - } - updateDirtySessionCache() { - // Re-serialize dirty sessions and update cache - for (const id of this.dirtySessions) { - const session = this.state.sessions[id]; - if (session) { - this.cachedSessionJsons.set(id, JSON.stringify(session)); - } - else { - this.cachedSessionJsons.delete(id); - } - } - this.dirtySessions.clear(); - } - pruneStaleCacheEntries() { - // Prune stale cache entries (sessions removed via direct state mutation) - if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) { - for (const cachedId of this.cachedSessionJsons.keys()) { - if (!(cachedId in this.state.sessions)) { - this.cachedSessionJsons.delete(cachedId); - } - } - } - } - buildPartialJson(sessionParts) { - // Build final JSON: sessions from cache, everything else re-serialized (tiny) - const sessionsJson = `{${sessionParts.join(',')}}`; - // Serialize non-session fields individually (they're small) - const parts = [ - `"sessions":${sessionsJson}`, - `"tasks":${JSON.stringify(this.state.tasks)}`, - `"ralphLoop":${JSON.stringify(this.state.ralphLoop)}`, - `"config":${JSON.stringify(this.state.config)}`, - ]; - // Optional fields - if (this.state.globalStats) { - parts.push(`"globalStats":${JSON.stringify(this.state.globalStats)}`); - } - if (this.state.tokenStats) { - parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`); - } - return `{${parts.join(',')}}`; - } - serializeState() { - try { - return this.assembleStateJson(); - } - catch (assembleErr) { - // Fallback to full serialization if incremental assembly fails - console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr); - this.cachedSessionJsons.clear(); - this.dirtySessions.clear(); - try { - return JSON.stringify(this.state); - } - catch (err) { - console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); - this.consecutiveSaveFailures++; - if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { - console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); - this.circuitBreakerOpen = true; - } - return null; - } - } - } - async _doSaveAsync() { - this.saveDeb.cancel(); - if (!this.dirty) { - return; - } - // Circuit breaker: stop attempting writes after too many failures - if (this.circuitBreakerOpen) { - console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); - return; - } - this.ensureDir(); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; - const backupPath = this.filePath + '.bak'; - // Step 1: Serialize state (validates it's JSON-safe) - const json = this.serializeState(); - if (json === null) - return; - // Clear dirty flag BEFORE async I/O so mutations during write re-set it. - // The state snapshot is already captured in `json` above. - this.dirty = false; - // Step 2: Create backup via file copy (async, no read+parse+write) - try { - await access(this.filePath); - await copyFile(this.filePath, backupPath); - } - catch { - // Backup failed or file doesn't exist yet - continue with write - } - // Step 3: Atomic write: write to temp file, then rename (async) - try { - await writeFile(tempPath, json, 'utf-8'); - await rename(tempPath, this.filePath); - this._resetCircuitBreaker(); - } - catch (err) { - console.error('[StateStore] Failed to write state file:', err); - // Re-mark dirty so the data is retried on the next save cycle - this.dirty = true; - this.consecutiveSaveFailures++; - // Try to clean up temp file on error - try { - await unlink(tempPath); - } - catch { - // Temp file may not exist - } - // Check circuit breaker threshold - if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { - console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); - this.circuitBreakerOpen = true; - } - } - } - /** - * Synchronous immediate write to disk using atomic write pattern. - * Used by flushAll() during shutdown when async is not appropriate. - * Prefer saveNowAsync() for normal operation. - */ - saveNow() { - this.saveDeb.cancel(); - if (!this.dirty) { - return; - } - if (this.circuitBreakerOpen) { - console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); - return; - } - this.ensureDir(); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; - const backupPath = this.filePath + '.bak'; - const json = this.serializeState(); - if (json === null) - return; - // Backup via atomic copy (avoids reading entire file into memory) - try { - if (existsSync(this.filePath)) { - copyFileSync(this.filePath, backupPath); - } - } - catch { - // Backup failed - continue with write - } - try { - writeFileSync(tempPath, json, 'utf-8'); - renameSync(tempPath, this.filePath); - // Clear dirty flag only AFTER successful write - this.dirty = false; - this._resetCircuitBreaker(); - } - catch (err) { - console.error('[StateStore] Failed to write state file:', err); - this.consecutiveSaveFailures++; - try { - if (existsSync(tempPath)) - unlinkSync(tempPath); - } - catch { - /* ignore */ - } - if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { - console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); - this.circuitBreakerOpen = true; - } - } - } - /** - * Attempt to recover state from backup file. - * Call this if main state file is corrupt. - */ - recoverFromBackup() { - const backupPath = this.filePath + '.bak'; - try { - if (existsSync(backupPath)) { - const backupContent = readFileSync(backupPath, 'utf-8'); - const parsed = JSON.parse(backupContent); - this.state = this._mergeWithInitialState(parsed); - console.log('[StateStore] Successfully recovered state from backup'); - // Reset circuit breaker after successful recovery - this.circuitBreakerOpen = false; - this.consecutiveSaveFailures = 0; - return true; - } - } - catch (err) { - console.error('[StateStore] Failed to recover from backup:', err); - } - return false; - } - /** - * Reset the circuit breaker (for manual intervention). - */ - resetCircuitBreaker() { - this.circuitBreakerOpen = false; - this.consecutiveSaveFailures = 0; - console.log('[StateStore] Circuit breaker manually reset'); - } - /** Flushes any pending main state save. Call before shutdown. */ - flush() { - this.saveNow(); - } - /** Returns the full application state object. */ - getState() { - return this.state; - } - /** Returns all session states keyed by session ID. */ - getSessions() { - return this.state.sessions; - } - /** Returns a session state by ID, or null if not found. */ - getSession(id) { - return this.state.sessions[id] ?? null; - } - /** Sets a session state and triggers a debounced save. */ - setSession(id, session) { - this.state.sessions[id] = session; - this.dirtySessions.add(id); - this.save(); - } - /** Removes a session state and triggers a debounced save. */ - removeSession(id) { - delete this.state.sessions[id]; - this.cachedSessionJsons.delete(id); - this.dirtySessions.delete(id); - this.save(); - } - /** - * Cleans up stale sessions from state that don't have corresponding active sessions. - * @param activeSessionIds - Set of currently active session IDs - * @returns Number of sessions cleaned up - */ - cleanupStaleSessions(activeSessionIds) { - const allSessionIds = Object.keys(this.state.sessions); - const cleaned = []; - for (const sessionId of allSessionIds) { - if (!activeSessionIds.has(sessionId)) { - const name = this.state.sessions[sessionId]?.name; - cleaned.push({ id: sessionId, name }); - delete this.state.sessions[sessionId]; - this.cachedSessionJsons.delete(sessionId); - this.dirtySessions.delete(sessionId); - // Also clean up Ralph state for this session - this.ralphStates.delete(sessionId); - } - } - if (cleaned.length > 0) { - console.log(`[StateStore] Cleaned up ${cleaned.length} stale session(s) from state`); - this.save(); - } - return { count: cleaned.length, cleaned }; - } - /** Returns all task states keyed by task ID. */ - getTasks() { - return this.state.tasks; - } - /** Returns a task state by ID, or null if not found. */ - getTask(id) { - return this.state.tasks[id] ?? null; - } - /** Sets a task state and triggers a debounced save. */ - setTask(id, task) { - this.state.tasks[id] = task; - this.save(); - } - /** Removes a task state and triggers a debounced save. */ - removeTask(id) { - delete this.state.tasks[id]; - this.save(); - } - /** Returns the Ralph Loop state. */ - getRalphLoopState() { - return this.state.ralphLoop; - } - /** Updates Ralph Loop state (partial merge) and triggers a debounced save. */ - setRalphLoopState(ralphLoop) { - this.state.ralphLoop = { ...this.state.ralphLoop, ...ralphLoop }; - this.save(); - } - // ========== Orchestrator Loop State Methods ========== - /** Returns the orchestrator loop state, or null if never initialized. */ - getOrchestratorState() { - return this.state.orchestrator ?? null; - } - /** Updates orchestrator loop state (partial merge) and triggers a debounced save. */ - setOrchestratorState(orchestrator) { - if (this.state.orchestrator) { - this.state.orchestrator = { ...this.state.orchestrator, ...orchestrator }; - } - else { - // First initialization — caller must provide full state - this.state.orchestrator = orchestrator; - } - this.save(); - } - /** Clears orchestrator state and triggers a debounced save. */ - clearOrchestratorState() { - this.state.orchestrator = undefined; - this.save(); - } - /** Returns the application configuration. */ - getConfig() { - return this.state.config; - } - /** Updates configuration (partial merge) and triggers a debounced save. */ - setConfig(config) { - this.state.config = { ...this.state.config, ...config }; - this.save(); - } - /** Resets all state to initial values and saves immediately. */ - reset() { - this.state = createInitialState(); - this.state.config.stateFilePath = this.filePath; - this.ralphStates.clear(); - this.cachedSessionJsons.clear(); - this.dirtySessions.clear(); - this.saveNow(); // Immediate save for reset operations - this.saveRalphStatesNow(); - } - // ========== Global Stats Methods ========== - /** Returns global stats, creating initial stats if needed. */ - getGlobalStats() { - if (!this.state.globalStats) { - this.state.globalStats = createInitialGlobalStats(); - } - return this.state.globalStats; - } - /** - * Adds tokens and cost to global stats. - * Call when a session is deleted to preserve its usage in lifetime stats. - */ - addToGlobalStats(inputTokens, outputTokens, cost) { - // Sanity check: reject absurdly large values - if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { - console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`); - return; - } - // Reject negative values - if (inputTokens < 0 || outputTokens < 0 || cost < 0) { - console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`); - return; - } - const stats = this.getGlobalStats(); - stats.totalInputTokens += inputTokens; - stats.totalOutputTokens += outputTokens; - stats.totalCost += cost; - stats.lastUpdatedAt = Date.now(); - this.save(); - } - /** Increments the total sessions created counter. */ - incrementSessionsCreated() { - const stats = this.getGlobalStats(); - stats.totalSessionsCreated += 1; - stats.lastUpdatedAt = Date.now(); - this.save(); - } - /** - * Returns aggregate stats combining global (deleted sessions) + active sessions. - * @param activeSessions Map of active session states - */ - getAggregateStats(activeSessions) { - const global = this.getGlobalStats(); - let activeInput = 0; - let activeOutput = 0; - let activeCost = 0; - let activeCount = 0; - for (const session of Object.values(activeSessions)) { - activeInput += session.inputTokens ?? 0; - activeOutput += session.outputTokens ?? 0; - activeCost += session.totalCost ?? 0; - activeCount++; - } - return { - totalInputTokens: global.totalInputTokens + activeInput, - totalOutputTokens: global.totalOutputTokens + activeOutput, - totalCost: global.totalCost + activeCost, - totalSessionsCreated: global.totalSessionsCreated, - activeSessionsCount: activeCount, - }; - } - // ========== Token Stats Methods (Daily Tracking) ========== - /** Maximum days to keep in daily history */ - static MAX_DAILY_HISTORY = 30; - /** - * Get or initialize token stats from state. - */ - getTokenStats() { - if (!this.state.tokenStats) { - this.state.tokenStats = { - daily: [], - lastUpdated: Date.now(), - }; - } - return this.state.tokenStats; - } - /** - * Get today's date string in YYYY-MM-DD format. - */ - getTodayDateString() { - const now = new Date(); - return now.toISOString().split('T')[0]; - } - /** - * Calculate estimated cost from tokens using Claude Opus pricing. - * Input: $15/M tokens, Output: $75/M tokens - */ - calculateEstimatedCost(inputTokens, outputTokens) { - const inputCost = (inputTokens / 1000000) * 15; - const outputCost = (outputTokens / 1000000) * 75; - return inputCost + outputCost; - } - // Track unique sessions per day for accurate session count - dailySessionIds = new Set(); - dailySessionDate = ''; - /** - * Record token usage for today. - * Accumulates tokens to today's entry, creating it if needed. - * @param inputTokens Input tokens to add - * @param outputTokens Output tokens to add - * @param sessionId Optional session ID for unique session counting - */ - recordDailyUsage(inputTokens, outputTokens, sessionId) { - if (inputTokens <= 0 && outputTokens <= 0) - return; - // Sanity check: reject absurdly large values (max 1M tokens per recording) - // Claude's context window is ~200k, so 1M per recording is already very generous - const MAX_TOKENS_PER_RECORDING = 1_000_000; - if (inputTokens > MAX_TOKENS_PER_RECORDING || outputTokens > MAX_TOKENS_PER_RECORDING) { - console.warn(`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`); - return; - } - const stats = this.getTokenStats(); - const today = this.getTodayDateString(); - // Reset daily session tracking on date change - if (this.dailySessionDate !== today) { - this.dailySessionIds.clear(); - this.dailySessionDate = today; - } - // Find or create today's entry - let todayEntry = stats.daily.find((e) => e.date === today); - if (!todayEntry) { - todayEntry = { - date: today, - inputTokens: 0, - outputTokens: 0, - estimatedCost: 0, - sessions: 0, - }; - stats.daily.unshift(todayEntry); // Add to front (most recent first) - } - // Accumulate tokens - todayEntry.inputTokens += inputTokens; - todayEntry.outputTokens += outputTokens; - todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens); - // Only increment session count for unique sessions - if (sessionId && !this.dailySessionIds.has(sessionId)) { - this.dailySessionIds.add(sessionId); - todayEntry.sessions = this.dailySessionIds.size; - } - // Prune old entries (keep last 30 days) - if (stats.daily.length > StateStore.MAX_DAILY_HISTORY) { - stats.daily = stats.daily.slice(0, StateStore.MAX_DAILY_HISTORY); - } - stats.lastUpdated = Date.now(); - this.save(); - } - /** - * Get daily stats for display. - * @param days Number of days to return (default: 30) - * @returns Array of daily entries, most recent first - */ - getDailyStats(days = 30) { - const stats = this.getTokenStats(); - return stats.daily.slice(0, days); - } - // ========== Inner State Methods (Ralph Loop tracking) ========== - loadRalphStates() { - try { - if (existsSync(this.ralphStatePath)) { - const data = readFileSync(this.ralphStatePath, 'utf-8'); - const parsed = JSON.parse(data); - for (const [sessionId, state] of Object.entries(parsed)) { - this.ralphStates.set(sessionId, state); - } - } - } - catch (err) { - console.error('Failed to load inner states:', err); - } - } - // Debounced save for inner states - saveRalphStates() { - this.ralphStateDirty = true; - if (this.ralphStateSaveDeb.isPending) - return; // Already scheduled - this.ralphStateSaveDeb.schedule(() => { - this.saveRalphStatesNow(); - }); - } - /** - * Immediate save for inner states using atomic write pattern. - * Writes to temp file first, then renames to prevent corruption on crash. - */ - saveRalphStatesNow() { - this.ralphStateSaveDeb.cancel(); - if (!this.ralphStateDirty) { - return; - } - // Clear dirty flag only on success to enable retry on failure - this.ensureDir(); - const data = Object.fromEntries(this.ralphStates); - // Atomic write: write to temp file, then rename (atomic on POSIX) - const tempPath = this.ralphStatePath + '.tmp'; - let json; - try { - json = JSON.stringify(data); - } - catch (err) { - console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err); - // Keep dirty flag true for retry - don't throw, let caller continue - return; - } - try { - writeFileSync(tempPath, json, 'utf-8'); - renameSync(tempPath, this.ralphStatePath); - // Success - clear dirty flag - this.ralphStateDirty = false; - } - catch (err) { - console.error('[StateStore] Failed to write Ralph state file:', err); - // Keep dirty flag true for retry on next save - // Try to clean up temp file on error - try { - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - } - catch (cleanupErr) { - console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr); - } - // Don't throw - let caller continue, retry on next save - } - } - /** Returns inner state for a session, or null if not found. */ - getRalphState(sessionId) { - return this.ralphStates.get(sessionId) ?? null; - } - /** Sets inner state for a session and triggers a debounced save. */ - setRalphState(sessionId, state) { - this.ralphStates.set(sessionId, state); - this.saveRalphStates(); - } - /** - * Updates inner state for a session (partial merge). - * Creates initial state if none exists. - * @returns The updated inner state. - */ - updateRalphState(sessionId, updates) { - let state = this.ralphStates.get(sessionId); - if (!state) { - state = createInitialRalphSessionState(sessionId); - } - state = { ...state, ...updates, lastUpdated: Date.now() }; - this.ralphStates.set(sessionId, state); - this.saveRalphStates(); - return state; - } - /** Removes inner state for a session and triggers a debounced save. */ - removeRalphState(sessionId) { - if (this.ralphStates.has(sessionId)) { - this.ralphStates.delete(sessionId); - this.saveRalphStates(); - } - } - /** Returns a copy of all inner states as a Map. */ - getAllRalphStates() { - return new Map(this.ralphStates); - } - /** Flushes all pending saves (main and inner state). Call before shutdown. */ - flushAll() { - // Save both states, catching errors to ensure both are attempted - let mainError = null; - let ralphError = null; - try { - this.saveNow(); - } - catch (err) { - mainError = err; - console.error('[StateStore] Error flushing main state:', err); - } - try { - this.saveRalphStatesNow(); - } - catch (err) { - ralphError = err; - console.error('[StateStore] Error flushing Ralph state:', err); - } - // Log summary if any errors occurred - if (mainError || ralphError) { - console.warn('[StateStore] flushAll completed with errors - some state may not be persisted'); - } - } -} -// Singleton instance -let storeInstance = null; -/** - * Gets or creates the singleton StateStore instance. - * @param filePath Optional custom file path (only used on first call). - */ -export function getStore(filePath) { - if (!storeInstance) { - storeInstance = new StateStore(filePath); - } - return storeInstance; -} -//# sourceMappingURL=state-store.js.map \ No newline at end of file diff --git a/scripts/claudeman-launchd-wrapper.sh b/scripts/claudeman-launchd-wrapper.sh deleted file mode 100755 index 3c07a3bb..00000000 --- a/scripts/claudeman-launchd-wrapper.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -# Claudeman launchd wrapper -# -# 根因: Node 25 被 launchd 直接拉起时 V8 bootstrapper 概率性死锁 -# (进程存在、端口不监听、日志空白、sample 显示卡在 LoadEnvironment) -# 手动 nohup 同样环境则正常。通过 bash wrapper + exec 绕过此问题。 -# -# 额外加固: -# - 启动前清理占 3000 端口的野进程 -# - 写启动日志到 stderr(被 launchd 重定向到 StandardErrorPath) - -set -euo pipefail - -PORT=3000 -CLAUDEMAN_DIR="/Users/teigen/Documents/Workspace/AI_project/Claudeman" - -export HOME=/Users/teigen -export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin - -echo "[wrapper] $(date '+%Y-%m-%d %H:%M:%S') starting claudeman web" >&2 - -# 清理占端口的野进程(非本进程树的残留 node) -STALE_PIDS=$(/usr/sbin/lsof -nP -iTCP:${PORT} -sTCP:LISTEN -t 2>/dev/null || true) -if [[ -n "$STALE_PIDS" ]]; then - echo "[wrapper] clearing stale processes on port ${PORT}: ${STALE_PIDS}" >&2 - for pid in $STALE_PIDS; do - kill "$pid" 2>/dev/null || true - done - sleep 2 -fi - -cd "$CLAUDEMAN_DIR" -exec /opt/homebrew/bin/node dist/index.js web --https -p "$PORT"