mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: session lifecycle audit log — append-only JSONL at ~/.claudeman/session-lifecycle.jsonl
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> = 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<LifecycleEntry, 'ts'> & { 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<LifecycleEntry[]> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
+7
-6
@@ -368,25 +368,26 @@ export class StateStore {
|
||||
* @param activeSessionIds - Set of currently active session IDs
|
||||
* @returns Number of sessions cleaned up
|
||||
*/
|
||||
cleanupStaleSessions(activeSessionIds: Set<string>): number {
|
||||
cleanupStaleSessions(activeSessionIds: Set<string>): { 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. */
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
// ========== Session Types ==========
|
||||
|
||||
/**
|
||||
|
||||
+60
-2
@@ -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 `<tr style="border-bottom:1px solid #1a1a2e">
|
||||
<td style="padding:3px 8px;color:#888;white-space:nowrap">${time}</td>
|
||||
<td style="padding:3px 8px;color:${color};font-weight:600">${e.event}</td>
|
||||
<td style="padding:3px 8px;color:#e0e0e0" title="${e.sessionId}">${name}</td>
|
||||
<td style="padding:3px 8px;color:#aaa">${e.reason || ''}</td>
|
||||
<td style="padding:3px 8px;color:#666">${extra.join(', ')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load lifecycle log:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async saveAppSettings() {
|
||||
const settings = {
|
||||
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header" onclick="app.openLifecycleLog()" title="Session Lifecycle Log" aria-label="Open session lifecycle log" style="font-size:14px;opacity:0.7">📋</button>
|
||||
<button class="btn-icon-header btn-settings" onclick="app.openAppSettings()" title="App Settings" aria-label="Open app settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
||||
</div>
|
||||
@@ -880,7 +881,7 @@
|
||||
|
||||
<!-- Input Options Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item" title="Show typed characters instantly via overlay for high-latency connections. Only active when session is idle.">
|
||||
<div class="settings-item" title="Buffer keystrokes locally and show them instantly via overlay. Text is sent to the server on Enter. Useful for high-latency connections or to compose input before sending.">
|
||||
<span class="settings-item-label">Local Echo</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsLocalEcho">
|
||||
@@ -1500,6 +1501,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Lifecycle Audit Log Modal -->
|
||||
<div class="modal" id="lifecycleModal">
|
||||
<div class="modal-backdrop" onclick="app.closeLifecycleLog()"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>Session Lifecycle Log</h3>
|
||||
<button class="modal-close" onclick="app.closeLifecycleLog()" aria-label="Close lifecycle log">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:70vh;overflow:auto;padding:8px">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
||||
<select id="lifecycleFilterEvent" style="background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;padding:4px 8px;font-size:12px">
|
||||
<option value="">All Events</option>
|
||||
<option value="created">Created</option>
|
||||
<option value="started">Started</option>
|
||||
<option value="exit">Exit</option>
|
||||
<option value="deleted">Deleted</option>
|
||||
<option value="recovered">Recovered</option>
|
||||
<option value="stale_cleaned">Stale Cleaned</option>
|
||||
<option value="mux_died">Mux Died</option>
|
||||
<option value="server_started">Server Started</option>
|
||||
<option value="server_stopped">Server Stopped</option>
|
||||
</select>
|
||||
<input id="lifecycleFilterSession" type="text" placeholder="Filter by session ID..." style="background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;padding:4px 8px;font-size:12px;flex:1;min-width:120px">
|
||||
<button class="btn btn-sm btn-secondary" onclick="app.loadLifecycleLog()" style="font-size:12px;padding:4px 10px">Refresh</button>
|
||||
</div>
|
||||
<table id="lifecycleTable" style="width:100%;border-collapse:collapse;font-size:12px;font-family:monospace">
|
||||
<thead>
|
||||
<tr style="color:#888;text-align:left;border-bottom:1px solid #333">
|
||||
<th style="padding:4px 8px">Time</th>
|
||||
<th style="padding:4px 8px">Event</th>
|
||||
<th style="padding:4px 8px">Session</th>
|
||||
<th style="padding:4px 8px">Reason</th>
|
||||
<th style="padding:4px 8px">Extra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lifecycleTableBody"></tbody>
|
||||
</table>
|
||||
<div id="lifecycleEmpty" style="color:#666;text-align:center;padding:20px;display:none">No lifecycle entries found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVG Overlay for Subagent Connection Lines -->
|
||||
<svg id="connectionLines" class="connection-lines-svg">
|
||||
<!-- Lines drawn dynamically -->
|
||||
|
||||
+78
-66
@@ -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<string, string[]> = new Map();
|
||||
private terminalBatchSizes: Map<string, number> = 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<string, string> = new Map();
|
||||
private outputBatchTimer: NodeJS.Timeout | null = null;
|
||||
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = 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<string, unknown>,
|
||||
});
|
||||
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<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
@@ -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<string> = new Set();
|
||||
|
||||
private async cleanupSession(sessionId: string, killMux: boolean = true): Promise<void> {
|
||||
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
|
||||
// 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<void> {
|
||||
private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>(resolve => setTimeout(resolve, 30_000));
|
||||
await Promise.race([sessionCleanup, shutdownTimeout]);
|
||||
|
||||
Reference in New Issue
Block a user