perf: fix memory leaks — strip task outputs from SSE broadcasts, use light state everywhere

- TaskTracker.getTaskTreeLight(): strips large `output` strings from tasks
  in SSE broadcasts (was serializing 5-10MB every 500ms with many subagents)
- session:created broadcasts now use toLightDetailedState() (consistent with
  session:updated which already did)
- GET /api/sessions/:id returns light state (no 2-3MB terminal+text buffers)
- Ralph wizard polls /terminal?tail=2048 instead of full session endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-20 06:50:59 +01:00
co-authored by Claude Opus 4.6
parent 51e45ed317
commit 868d4f1196
8 changed files with 47 additions and 28 deletions
+1 -1
View File
@@ -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.1570 (must match `package.json` for npm publish)
**Version**: 0.1571 (must match `package.json` for npm publish)
## Project Overview
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1570",
"version": "0.1571",
"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",
+2 -2
View File
@@ -789,9 +789,9 @@ export class Session extends EventEmitter {
maxTextOutput: MAX_TEXT_OUTPUT_SIZE,
maxMessages: MAX_MESSAGES,
},
// Background task tracking
// Background task tracking (light tree strips large output strings)
taskStats: this._taskTracker.getStats(),
taskTree: this._taskTracker.getTaskTree(),
taskTree: this._taskTracker.getTaskTreeLight(),
// Token tracking
tokens: {
input: this._totalInputTokens,
+21
View File
@@ -547,6 +547,27 @@ export class TaskTracker extends EventEmitter {
return rootTasks;
}
/**
* Lightweight task tree for SSE broadcasts — strips large `output` strings
* to avoid serializing 5-10MB of task results every 500ms.
* Full task details available via getTaskTree().
*
* @returns Array of root tasks with output truncated to 200 chars
*/
getTaskTreeLight(): Omit<BackgroundTask, 'output'>[] {
const rootTasks: Omit<BackgroundTask, 'output'>[] = [];
for (const task of this.tasks.values()) {
if (!task.parentId) {
// Strip output to avoid serializing large strings on every broadcast
const { output: _output, ...lightTask } = task;
rootTasks.push(lightTask);
}
}
return rootTasks;
}
/**
* Get all tasks as a flat Map.
*
+1 -5
View File
@@ -692,16 +692,12 @@ export function createErrorResponse(code: ApiErrorCode, details?: string): ApiRe
export interface SessionResponse {
/** Whether the request succeeded */
success: boolean;
/** Session details if successful */
/** Session details if successful (light state — no full buffers) */
session?: SessionState & {
/** Claude session ID from CLI */
claudeSessionId: string | null;
/** Total API cost */
totalCost: number;
/** Text output buffer */
textOutput: string;
/** Terminal buffer */
terminalBuffer: string;
/** Number of messages */
messageCount: number;
/** Whether Claude is working */
+10 -4
View File
@@ -7716,7 +7716,8 @@ class ClaudemanApp {
while (attempts < maxAttempts) {
await new Promise(r => setTimeout(r, 500));
try {
const statusRes = await fetch(`/api/sessions/${sessionId}`);
// Use /terminal?tail=2048 instead of /sessions/:id to avoid fetching 2-3MB full state
const statusRes = await fetch(`/api/sessions/${sessionId}/terminal?tail=2048`);
const statusData = await statusRes.json();
// Session is ready ONLY when Claude CLI shows its UI:
// Must see prompt character '❯' OR 'tokens' status line
@@ -7781,9 +7782,14 @@ class ClaudemanApp {
for (let attempt = 0; attempt < initMaxAttempts; attempt++) {
await new Promise(r => setTimeout(r, 1000));
try {
const statusRes = await fetch(`/api/sessions/${sessionId}`);
const statusData = await statusRes.json();
const termBuf = statusData?.terminalBuffer || '';
// Fetch terminal tail (lightweight) + session state separately to avoid 2-3MB full buffer
const [termRes, stateRes] = await Promise.all([
fetch(`/api/sessions/${sessionId}/terminal?tail=2048`),
fetch(`/api/sessions/${sessionId}`)
]);
const termData = await termRes.json();
const statusData = await stateRes.json();
const termBuf = termData?.terminalBuffer || '';
// /init is complete when we see the prompt indicator (❯) and not working
const hasPrompt = termBuf.includes('❯');
const isIdle = !statusData.isWorking;
+11 -15
View File
@@ -47,7 +47,6 @@ import {
ApiErrorCode,
createErrorResponse,
type ApiResponse,
type SessionResponse,
type QuickStartResponse,
type CaseInfo,
type PersistedRespawnConfig,
@@ -809,7 +808,7 @@ export class WebServer extends EventEmitter {
// Session management
this.app.get('/api/sessions', async () => this.getLightSessionsState());
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
this.app.post('/api/sessions', async (req) => {
// Prevent unbounded session creation
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`);
@@ -858,9 +857,11 @@ export class WebServer extends EventEmitter {
await this.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
const detailedState = session.toDetailedState();
this.broadcast('session:created', detailedState);
return { success: true, session: detailedState };
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
const lightState = this.getSessionStateWithRespawn(session);
this.broadcast('session:created', lightState);
return { success: true, session: lightState };
});
// Rename a session
@@ -947,14 +948,9 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Include respawn controller state if active
const controller = this.respawnControllers.get(id);
return {
...session.toDetailedState(),
respawnEnabled: controller?.getConfig()?.enabled ?? false,
respawnConfig: controller?.getConfig() ?? null,
respawn: controller?.getStatus() ?? null,
};
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
return this.getSessionStateWithRespawn(session);
});
this.app.get('/api/sessions/:id/output', async (req) => {
@@ -2242,7 +2238,7 @@ export class WebServer extends EventEmitter {
await this.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'run_prompt' });
this.broadcast('session:created', session.toDetailedState());
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
try {
const result = await session.runPrompt(prompt);
@@ -2660,7 +2656,7 @@ export class WebServer extends EventEmitter {
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());
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
// Start in the appropriate mode
try {