From 383ed5e5a0d0e6ca46a483a04b24338389f984d7 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 19 Feb 2026 18:30:14 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20session=20lifecycle=20audit=20log=20?= =?UTF-8?q?=E2=80=94=20append-only=20JSONL=20at=20~/.claudeman/session-lif?= =?UTF-8?q?ecycle.jsonl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records every session create, start, exit, delete, recover, stale-clean, mux-died, and server start/stop with timestamps, names, reasons, and exit codes. Survives server restarts (unlike in-memory RunSummary). - New SessionLifecycleLog singleton (src/session-lifecycle-log.ts) - LifecycleEventType + LifecycleEntry types in types.ts - GET /api/session-lifecycle endpoint with sessionId/event/since/limit filters - cleanupSession() now takes reason parameter for audit trail - state-store cleanupStaleSessions returns cleaned session IDs+names - UI: lifecycle log modal accessible from header clipboard icon - Auto-trims to 8k entries when exceeding 10k on server start chore: bump version to 0.1556 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- package.json | 2 +- src/respawn-controller.ts | 4 +- src/session-lifecycle-log.ts | 114 +++++++++++++++++++++++++++ src/state-store.ts | 13 ++-- src/types.ts | 26 +++++++ src/web/public/app.js | 62 ++++++++++++++- src/web/public/index.html | 45 ++++++++++- src/web/server.ts | 144 +++++++++++++++++++---------------- 9 files changed, 333 insertions(+), 79 deletions(-) create mode 100644 src/session-lifecycle-log.ts diff --git a/CLAUDE.md b/CLAUDE.md index d43b630c..90a15f74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1555 (must match `package.json` for npm publish) +**Version**: 0.1556 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 814345a6..116e0911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1555", + "version": "0.1556", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index d5437c6f..da3113f7 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -1160,7 +1160,7 @@ export class RespawnController extends EventEmitter { /** * Start periodic detection status updates for UI. - * Emits 'detectionUpdate' event every 500ms while running. + * Emits 'detectionUpdate' event every 2s while running. */ private startDetectionUpdates(): void { this.stopDetectionUpdates(); @@ -1173,7 +1173,7 @@ export class RespawnController extends EventEmitter { } catch (err) { console.error(`[RespawnController] Error in detectionUpdateTimer:`, err); } - }, 500); + }, 2000); } /** diff --git a/src/session-lifecycle-log.ts b/src/session-lifecycle-log.ts new file mode 100644 index 00000000..4aa1262e --- /dev/null +++ b/src/session-lifecycle-log.ts @@ -0,0 +1,114 @@ +/** + * @fileoverview Append-only JSONL audit log for session lifecycle events. + * + * Records every session creation, start, exit, deletion, recovery, and server + * start/stop to ~/.claudeman/session-lifecycle.jsonl. Survives server restarts + * (unlike RunSummary which is in-memory only). + * + * @module session-lifecycle-log + */ + +import { appendFile, readFile, writeFile } from 'node:fs/promises'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import type { LifecycleEventType, LifecycleEntry } from './types.js'; + +const MAX_LINES = 10_000; +const TRIM_TO = 8_000; + +export class SessionLifecycleLog { + private filePath: string; + private writeQueue: Promise = Promise.resolve(); + + constructor(filePath?: string) { + this.filePath = filePath || join(homedir(), '.claudeman', 'session-lifecycle.jsonl'); + const dir = dirname(this.filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + } + + /** + * Append a lifecycle event. Fire-and-forget — errors are logged but never thrown. + */ + log(entry: Omit & { ts?: number }): void { + const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n'; + // Chain writes to prevent interleaving + this.writeQueue = this.writeQueue + .then(() => appendFile(this.filePath, line, 'utf-8')) + .catch((err) => { + console.error('[LifecycleLog] Failed to write:', err); + }); + } + + /** + * Query the log file with optional filters. + */ + async query(opts?: { + sessionId?: string; + event?: LifecycleEventType; + since?: number; + limit?: number; + }): Promise { + const limit = opts?.limit ?? 200; + + let raw: string; + try { + raw = await readFile(this.filePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + + const lines = raw.trim().split('\n').filter(Boolean); + let entries: LifecycleEntry[] = []; + + // Parse in reverse (newest first) for efficiency with limit + for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) { + try { + const entry = JSON.parse(lines[i]) as LifecycleEntry; + + if (opts?.sessionId && entry.sessionId !== opts.sessionId) continue; + if (opts?.event && entry.event !== opts.event) continue; + if (opts?.since && entry.ts < opts.since) continue; + + entries.push(entry); + } catch { + // Skip malformed lines + } + } + + return entries; + } + + /** + * Trim the log file if it exceeds MAX_LINES. Called on server start. + */ + async trimIfNeeded(): Promise { + let raw: string; + try { + raw = await readFile(this.filePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + + const lines = raw.trim().split('\n').filter(Boolean); + if (lines.length <= MAX_LINES) return; + + const trimmed = lines.slice(-TRIM_TO); + await writeFile(this.filePath, trimmed.join('\n') + '\n', 'utf-8'); + console.log(`[LifecycleLog] Trimmed from ${lines.length} to ${trimmed.length} entries`); + } +} + +// Singleton +let instance: SessionLifecycleLog | null = null; + +export function getLifecycleLog(filePath?: string): SessionLifecycleLog { + if (!instance) { + instance = new SessionLifecycleLog(filePath); + } + return instance; +} diff --git a/src/state-store.ts b/src/state-store.ts index 61e9cdb8..5d64f335 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -368,25 +368,26 @@ export class StateStore { * @param activeSessionIds - Set of currently active session IDs * @returns Number of sessions cleaned up */ - cleanupStaleSessions(activeSessionIds: Set): number { + cleanupStaleSessions(activeSessionIds: Set): { count: number; cleaned: Array<{ id: string; name?: string }> } { const allSessionIds = Object.keys(this.state.sessions); - let cleanedCount = 0; + const cleaned: Array<{ id: string; name?: string }> = []; 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]; // Also clean up Ralph state for this session this.ralphStates.delete(sessionId); - cleanedCount++; } } - if (cleanedCount > 0) { - console.log(`[StateStore] Cleaned up ${cleanedCount} stale session(s) from state`); + if (cleaned.length > 0) { + console.log(`[StateStore] Cleaned up ${cleaned.length} stale session(s) from state`); this.save(); } - return cleanedCount; + return { count: cleaned.length, cleaned }; } /** Returns all task states keyed by task ID. */ diff --git a/src/types.ts b/src/types.ts index db9f1bfa..81d1d9cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,6 +76,32 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' /** TDD phase categories */ export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review'; +// ========== Session Lifecycle Audit Types ========== + +/** Types of session lifecycle events recorded to the audit log */ +export type LifecycleEventType = + | 'created' // Session object created + | 'started' // PTY process launched (interactive/shell/prompt) + | 'exit' // PTY process exited (with exit code) + | 'deleted' // cleanupSession() called — session removed + | 'recovered' // Session restored from tmux on server restart + | 'stale_cleaned' // Removed from state.json by cleanupStaleSessions() + | 'mux_died' // tmux session died (detected by reconciliation) + | 'server_started' // Server started (marker for restart detection) + | 'server_stopped'; // Server shutting down + +/** A single entry in the session lifecycle audit log */ +export interface LifecycleEntry { + ts: number; + event: LifecycleEventType; + sessionId: string; + name?: string; + mode?: string; + reason?: string; + exitCode?: number | null; + extra?: Record; +} + // ========== Session Types ========== /** diff --git a/src/web/public/app.js b/src/web/public/app.js index eaa3a906..590a04ec 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2358,7 +2358,7 @@ class ClaudemanApp { _updateLocalEchoState() { const settings = this.loadAppSettingsFromStorage(); const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; - const echoEnabled = settings.localEchoEnabled ?? (MobileDetection.isTouchDevice() || false); + const echoEnabled = settings.localEchoEnabled ?? true; const shouldEnable = !!(echoEnabled && session); if (this._localEchoEnabled && !shouldEnable) { this._localEchoOverlay?.clear(); @@ -9796,7 +9796,7 @@ class ClaudemanApp { document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; - document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? (MobileDetection.isTouchDevice() || false); + document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? true; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; // Claude CLI settings const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); @@ -9906,6 +9906,64 @@ class ClaudemanApp { } } + // ========== Session Lifecycle Log ========== + + openLifecycleLog() { + document.getElementById('lifecycleModal').classList.add('active'); + this.loadLifecycleLog(); + } + + closeLifecycleLog() { + document.getElementById('lifecycleModal').classList.remove('active'); + } + + async loadLifecycleLog() { + const eventFilter = document.getElementById('lifecycleFilterEvent').value; + const sessionFilter = document.getElementById('lifecycleFilterSession').value.trim(); + const params = new URLSearchParams(); + if (eventFilter) params.set('event', eventFilter); + if (sessionFilter) params.set('sessionId', sessionFilter); + params.set('limit', '300'); + + try { + const res = await fetch(`/api/session-lifecycle?${params}`); + const data = await res.json(); + const tbody = document.getElementById('lifecycleTableBody'); + const empty = document.getElementById('lifecycleEmpty'); + + if (!data.entries || data.entries.length === 0) { + tbody.innerHTML = ''; + empty.style.display = ''; + return; + } + empty.style.display = 'none'; + + const eventColors = { + created: '#4ade80', started: '#4ade80', recovered: '#4ade80', + exit: '#fbbf24', mux_died: '#f87171', deleted: '#f87171', stale_cleaned: '#f87171', + server_started: '#666', server_stopped: '#666', + }; + + tbody.innerHTML = data.entries.map(e => { + const time = new Date(e.ts).toLocaleString(); + const color = eventColors[e.event] || '#888'; + const name = e.name || (e.sessionId === '*' ? '—' : e.sessionId.slice(0, 8)); + const extra = []; + if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`); + if (e.mode) extra.push(e.mode); + return ` + ${time} + ${e.event} + ${name} + ${e.reason || ''} + ${extra.join(', ')} + `; + }).join(''); + } catch (err) { + console.error('Failed to load lifecycle log:', err); + } + } + async saveAppSettings() { const settings = { defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(), diff --git a/src/web/public/index.html b/src/web/public/index.html index 585b046e..7d4878b3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -84,6 +84,7 @@ +
0 tokens
@@ -880,7 +881,7 @@
Input
-
+
Local Echo
+ + + diff --git a/src/web/server.ts b/src/web/server.ts index 51040133..41ba8c7a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -37,6 +37,7 @@ import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'node:module'; import { RunSummaryTracker } from '../run-summary.js'; import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js'; +import { getLifecycleLog } from '../session-lifecycle-log.js'; // Load version from package.json const require = createRequire(import.meta.url); @@ -110,8 +111,6 @@ interface ScheduledRun { // Batch terminal data for performance - collect for 16ms (60fps) before sending const TERMINAL_BATCH_INTERVAL = 16; -// Batch session:output events for 50ms -const OUTPUT_BATCH_INTERVAL = 50; // Batch task:updated events for 100ms const TASK_UPDATE_BATCH_INTERVAL = 100; @@ -306,7 +305,6 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } { /** Stored listener references for session cleanup (prevents memory leaks) */ interface SessionListenerRefs { - output: (data: string) => void; terminal: (data: string) => void; clearTerminal: () => void; message: (msg: ClaudeMessage) => void; @@ -356,6 +354,7 @@ export class WebServer extends EventEmitter { private mux: TerminalMultiplexer; // Terminal batching for performance private terminalBatches: Map = new Map(); + private terminalBatchSizes: Map = new Map(); // Running total avoids O(n) reduce per push private terminalBatchTimer: NodeJS.Timeout | null = null; // Adaptive batching: track rapid events to extend batch window (per-session) // StaleExpirationMap auto-cleans entries for sessions that stop generating output @@ -370,8 +369,6 @@ export class WebServer extends EventEmitter { // Scheduled runs cleanup timer private scheduledCleanupTimer: NodeJS.Timeout | null = null; // SSE event batching - private outputBatches: Map = new Map(); - private outputBatchTimer: NodeJS.Timeout | null = null; private taskUpdateBatches: Map = new Map(); private taskUpdateBatchTimer: NodeJS.Timeout | null = null; // State update batching (reduce expensive toDetailedState() serialization) @@ -444,6 +441,11 @@ export class WebServer extends EventEmitter { this.broadcast('mux:killed', data); }); this.mux.on('sessionDied', (data) => { + getLifecycleLog().log({ + event: 'mux_died', + sessionId: (data as { sessionId?: string }).sessionId || 'unknown', + extra: data as Record, + }); this.broadcast('mux:died', data); }); this.mux.on('statsUpdated', (sessions) => { @@ -673,6 +675,19 @@ export class WebServer extends EventEmitter { return { success: true, cleanedSessions: cleaned }; }); + // Session lifecycle audit log + this.app.get('/api/session-lifecycle', async (req) => { + const query = req.query as { sessionId?: string; event?: string; since?: string; limit?: string }; + const lifecycleLog = getLifecycleLog(); + const entries = await lifecycleLog.query({ + sessionId: query.sessionId, + event: query.event as import('../types.js').LifecycleEventType, + since: query.since ? Number(query.since) : undefined, + limit: query.limit ? Math.min(Number(query.limit), 1000) : 200, + }); + return { success: true, entries }; + }); + // Global stats endpoint this.app.get('/api/stats', async () => { const activeSessionTokens: Record = {}; @@ -737,7 +752,6 @@ export class WebServer extends EventEmitter { transcriptWatchers: this.transcriptWatchers.size, scheduledRuns: this.scheduledRuns.size, terminalBatches: this.terminalBatches.size, - outputBatches: this.outputBatches.size, taskUpdateBatches: this.taskUpdateBatches.size, stateUpdatePending: this.stateUpdatePending.size, lastRecordedTokens: this.lastRecordedTokens.size, @@ -842,6 +856,7 @@ export class WebServer extends EventEmitter { this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); const detailedState = session.toDetailedState(); this.broadcast('session:created', detailedState); @@ -905,7 +920,7 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - await this.cleanupSession(id, killMux); + await this.cleanupSession(id, killMux, 'user_delete'); return { success: true }; }); @@ -916,7 +931,7 @@ export class WebServer extends EventEmitter { for (const id of sessionIds) { if (this.sessions.has(id)) { - await this.cleanupSession(id); + await this.cleanupSession(id, true, 'user_bulk_delete'); killed++; } } @@ -1641,6 +1656,7 @@ export class WebServer extends EventEmitter { } await session.startInteractive(); + getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude' }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -1665,6 +1681,7 @@ export class WebServer extends EventEmitter { try { await session.startShell(); + getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'shell' }); this.broadcast('session:interactive', { id, mode: 'shell' }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); return { success: true }; @@ -1988,6 +2005,7 @@ export class WebServer extends EventEmitter { // Start interactive session await session.startInteractive(); + getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude', reason: 'interactive_respawn' }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -2222,17 +2240,18 @@ export class WebServer extends EventEmitter { this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'run_prompt' }); this.broadcast('session:created', session.toDetailedState()); try { const result = await session.runPrompt(prompt); // Clean up session after completion to prevent memory leak - await this.cleanupSession(session.id); + await this.cleanupSession(session.id, true, 'run_prompt_complete'); return { success: true, sessionId: session.id, ...result }; } catch (err) { // Clean up session on error too - await this.cleanupSession(session.id); + await this.cleanupSession(session.id, true, 'run_prompt_error'); return { success: false, sessionId: session.id, error: getErrorMessage(err) }; } }); @@ -2640,15 +2659,18 @@ export class WebServer extends EventEmitter { this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'quick_start' }); this.broadcast('session:created', session.toDetailedState()); // Start in the appropriate mode try { if (mode === 'shell') { await session.startShell(); + getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'shell' }); this.broadcast('session:interactive', { id: session.id, mode: 'shell' }); } else { await session.startInteractive(); + getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'claude' }); this.broadcast('session:interactive', { id: session.id }); } this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -2685,7 +2707,7 @@ export class WebServer extends EventEmitter { }; } catch (err) { // Clean up session on error to prevent orphaned resources - await this.cleanupSession(session.id); + await this.cleanupSession(session.id, true, 'quick_start_error'); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); @@ -3961,20 +3983,28 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Track sessions currently being cleaned up to prevent concurrent cleanup races private cleaningUp: Set = new Set(); - private async cleanupSession(sessionId: string, killMux: boolean = true): Promise { + private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise { // Guard against concurrent cleanup of the same session if (this.cleaningUp.has(sessionId)) return; this.cleaningUp.add(sessionId); try { - await this._doCleanupSession(sessionId, killMux); + await this._doCleanupSession(sessionId, killMux, reason); } finally { this.cleaningUp.delete(sessionId); } } - private async _doCleanupSession(sessionId: string, killMux: boolean): Promise { + private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise { const session = this.sessions.get(sessionId); + const lifecycleLog = getLifecycleLog(); + lifecycleLog.log({ + event: 'deleted', + sessionId, + name: session?.name, + mode: session?.mode, + reason: reason || 'unknown', + }); // Stop watching @fix_plan.md for this session if (session) { @@ -4035,7 +4065,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Clear batches and pending state updates this.terminalBatches.delete(sessionId); - this.outputBatches.delete(sessionId); + this.terminalBatchSizes.delete(sessionId); this.taskUpdateBatches.delete(sessionId); this.stateUpdatePending.delete(sessionId); this.lastTerminalEventTime.delete(sessionId); @@ -4080,7 +4110,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Explicitly remove stored listeners to break closure references (prevents memory leak) const listeners = this.sessionListenerRefs.get(sessionId); if (listeners) { - session.off('output', listeners.output); session.off('terminal', listeners.terminal); session.off('clearTerminal', listeners.clearTerminal); session.off('message', listeners.message); @@ -4142,11 +4171,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Store all listener references for explicit cleanup on session delete // This prevents memory leaks from closure references keeping objects alive const listeners: SessionListenerRefs = { - output: (data) => { - // Use batching for better performance at high throughput - this.batchOutputData(session.id, data); - }, - terminal: (data) => { // Use batching for better performance at high throughput this.batchTerminalData(session.id, data); @@ -4178,6 +4202,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }, exit: (code) => { + getLifecycleLog().log({ event: 'exit', sessionId: session.id, name: session.name, exitCode: code }); // Wrap in try/catch to ensure cleanup always happens try { this.broadcast('session:exit', { id: session.id, code }); @@ -4352,7 +4377,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.sessionListenerRefs.set(session.id, listeners); // Attach all listeners to the session - session.on('output', listeners.output); session.on('terminal', listeners.terminal); session.on('clearTerminal', listeners.clearTerminal); session.on('message', listeners.message); @@ -4721,7 +4745,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.broadcast('scheduled:updated', run); // Clean up the session after iteration to prevent memory leaks - await this.cleanupSession(session.id); + await this.cleanupSession(session.id, true, 'scheduled_run'); run.sessionId = null; // Small pause between iterations @@ -4733,7 +4757,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Clean up the session on error too if (session) { try { - await this.cleanupSession(session.id); + await this.cleanupSession(session.id, true, 'scheduled_run_error'); } catch { // Ignore cleanup errors } @@ -4762,7 +4786,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Use cleanupSession for proper resource cleanup (listeners, respawn, etc.) if (run.sessionId && this.sessions.has(run.sessionId)) { - await this.cleanupSession(run.sessionId); + await this.cleanupSession(run.sessionId, true, 'scheduled_run_stopped'); run.sessionId = null; } @@ -4832,7 +4856,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; */ private cleanupStaleSessions(): number { const activeSessionIds = new Set(this.sessions.keys()); - return this.store.cleanupStaleSessions(activeSessionIds); + const result = this.store.cleanupStaleSessions(activeSessionIds); + const lifecycleLog = getLifecycleLog(); + for (const s of result.cleaned) { + lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); + } + return result.count; } /** @@ -4909,8 +4938,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } private broadcast(event: string, data: unknown): void { - // Invalidate caches on any state-changing broadcast - if (event.startsWith('session:') || event.startsWith('respawn:')) { + // Invalidate caches on state-changing broadcasts, but NOT on high-frequency + // streaming events that don't change session metadata (terminal data, + // detection updates). These fire every 16ms-2s and would make the 1s TTL + // caches permanently empty — defeating their purpose. + if ( + (event.startsWith('session:') || event.startsWith('respawn:')) && + event !== 'session:terminal' && + event !== 'session:needsRefresh' && + event !== 'respawn:detectionUpdate' + ) { this.cachedLightState = null; this.cachedSessionsList = null; } @@ -4940,7 +4977,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.terminalBatches.set(sessionId, chunks); } chunks.push(data); - const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); + const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0; + const totalLength = prevSize + data.length; + this.terminalBatchSizes.set(sessionId, totalLength); // Adaptive batching: detect rapid events and extend batch window (per-session) const now = Date.now(); @@ -4991,6 +5030,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Skip if server is stopping (timer may have been queued before stop() was called) if (this._isStopping) { this.terminalBatches.clear(); + this.terminalBatchSizes.clear(); return; } for (const [sessionId, chunks] of this.terminalBatches) { @@ -5006,36 +5046,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } } this.terminalBatches.clear(); - } - - // Batch session:output events at 50ms for better performance - private batchOutputData(sessionId: string, data: string): void { - // Skip if server is stopping - if (this._isStopping) return; - - const existing = this.outputBatches.get(sessionId) || ''; - this.outputBatches.set(sessionId, existing + data); - - if (!this.outputBatchTimer) { - this.outputBatchTimer = setTimeout(() => { - this.flushOutputBatches(); - this.outputBatchTimer = null; - }, OUTPUT_BATCH_INTERVAL); - } - } - - private flushOutputBatches(): void { - // Skip if server is stopping (timer may have been queued before stop() was called) - if (this._isStopping) { - this.outputBatches.clear(); - return; - } - for (const [sessionId, data] of this.outputBatches) { - if (data.length > 0) { - this.broadcast('session:output', { id: sessionId, data }); - } - } - this.outputBatches.clear(); + this.terminalBatchSizes.clear(); } // Batch task:updated events at 100ms - only send latest update per task @@ -5161,6 +5172,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; async start(): Promise { await this.setupRoutes(); + const lifecycleLog = getLifecycleLog(); + lifecycleLog.log({ event: 'server_started', sessionId: '*' }); + await lifecycleLog.trimIfNeeded(); + // Restore mux sessions BEFORE accepting connections // This prevents race conditions where clients connect before state is ready // CRITICAL: Skip in test mode to prevent tests from picking up user sessions @@ -5391,6 +5406,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.persistSessionState(session); // Mark it as restored (not started yet - user needs to attach) + getLifecycleLog().log({ event: 'recovered', sessionId: session.id, name: session.name }); console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`); } } @@ -5415,6 +5431,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } async stop(): Promise { + getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' }); // Set stopping flag to prevent new timer creation during shutdown this._isStopping = true; @@ -5443,12 +5460,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.terminalBatchTimer = null; } this.terminalBatches.clear(); - - if (this.outputBatchTimer) { - clearTimeout(this.outputBatchTimer); - this.outputBatchTimer = null; - } - this.outputBatches.clear(); + this.terminalBatchSizes.clear(); if (this.taskUpdateBatchTimer) { clearTimeout(this.taskUpdateBatchTimer); @@ -5514,7 +5526,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Don't kill mux sessions on server stop - they can be reattached on restart // Use Promise.race with a 30s timeout to prevent shutdown from hanging indefinitely const sessionCleanup = Promise.allSettled( - Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false)) + Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false, 'server_shutdown')) ); const shutdownTimeout = new Promise(resolve => setTimeout(resolve, 30_000)); await Promise.race([sessionCleanup, shutdownTimeout]);