mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
@@ -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`)
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+2
-2
@@ -789,9 +789,9 @@ export class Session extends EventEmitter {
|
|||||||
maxTextOutput: MAX_TEXT_OUTPUT_SIZE,
|
maxTextOutput: MAX_TEXT_OUTPUT_SIZE,
|
||||||
maxMessages: MAX_MESSAGES,
|
maxMessages: MAX_MESSAGES,
|
||||||
},
|
},
|
||||||
// Background task tracking
|
// Background task tracking (light tree strips large output strings)
|
||||||
taskStats: this._taskTracker.getStats(),
|
taskStats: this._taskTracker.getStats(),
|
||||||
taskTree: this._taskTracker.getTaskTree(),
|
taskTree: this._taskTracker.getTaskTreeLight(),
|
||||||
// Token tracking
|
// Token tracking
|
||||||
tokens: {
|
tokens: {
|
||||||
input: this._totalInputTokens,
|
input: this._totalInputTokens,
|
||||||
|
|||||||
@@ -547,6 +547,27 @@ export class TaskTracker extends EventEmitter {
|
|||||||
return rootTasks;
|
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.
|
* Get all tasks as a flat Map.
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-5
@@ -692,16 +692,12 @@ export function createErrorResponse(code: ApiErrorCode, details?: string): ApiRe
|
|||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
/** Whether the request succeeded */
|
/** Whether the request succeeded */
|
||||||
success: boolean;
|
success: boolean;
|
||||||
/** Session details if successful */
|
/** Session details if successful (light state — no full buffers) */
|
||||||
session?: SessionState & {
|
session?: SessionState & {
|
||||||
/** Claude session ID from CLI */
|
/** Claude session ID from CLI */
|
||||||
claudeSessionId: string | null;
|
claudeSessionId: string | null;
|
||||||
/** Total API cost */
|
/** Total API cost */
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
/** Text output buffer */
|
|
||||||
textOutput: string;
|
|
||||||
/** Terminal buffer */
|
|
||||||
terminalBuffer: string;
|
|
||||||
/** Number of messages */
|
/** Number of messages */
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
/** Whether Claude is working */
|
/** Whether Claude is working */
|
||||||
|
|||||||
+10
-4
@@ -7716,7 +7716,8 @@ class ClaudemanApp {
|
|||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
try {
|
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();
|
const statusData = await statusRes.json();
|
||||||
// Session is ready ONLY when Claude CLI shows its UI:
|
// Session is ready ONLY when Claude CLI shows its UI:
|
||||||
// Must see prompt character '❯' OR 'tokens' status line
|
// Must see prompt character '❯' OR 'tokens' status line
|
||||||
@@ -7781,9 +7782,14 @@ class ClaudemanApp {
|
|||||||
for (let attempt = 0; attempt < initMaxAttempts; attempt++) {
|
for (let attempt = 0; attempt < initMaxAttempts; attempt++) {
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
try {
|
try {
|
||||||
const statusRes = await fetch(`/api/sessions/${sessionId}`);
|
// Fetch terminal tail (lightweight) + session state separately to avoid 2-3MB full buffer
|
||||||
const statusData = await statusRes.json();
|
const [termRes, stateRes] = await Promise.all([
|
||||||
const termBuf = statusData?.terminalBuffer || '';
|
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
|
// /init is complete when we see the prompt indicator (❯) and not working
|
||||||
const hasPrompt = termBuf.includes('❯');
|
const hasPrompt = termBuf.includes('❯');
|
||||||
const isIdle = !statusData.isWorking;
|
const isIdle = !statusData.isWorking;
|
||||||
|
|||||||
+11
-15
@@ -47,7 +47,6 @@ import {
|
|||||||
ApiErrorCode,
|
ApiErrorCode,
|
||||||
createErrorResponse,
|
createErrorResponse,
|
||||||
type ApiResponse,
|
type ApiResponse,
|
||||||
type SessionResponse,
|
|
||||||
type QuickStartResponse,
|
type QuickStartResponse,
|
||||||
type CaseInfo,
|
type CaseInfo,
|
||||||
type PersistedRespawnConfig,
|
type PersistedRespawnConfig,
|
||||||
@@ -809,7 +808,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// Session management
|
// Session management
|
||||||
this.app.get('/api/sessions', async () => this.getLightSessionsState());
|
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
|
// Prevent unbounded session creation
|
||||||
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`);
|
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);
|
await this.setupSessionListeners(session);
|
||||||
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
||||||
|
|
||||||
const detailedState = session.toDetailedState();
|
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
||||||
this.broadcast('session:created', detailedState);
|
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
||||||
return { success: true, session: detailedState };
|
const lightState = this.getSessionStateWithRespawn(session);
|
||||||
|
this.broadcast('session:created', lightState);
|
||||||
|
return { success: true, session: lightState };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rename a session
|
// Rename a session
|
||||||
@@ -947,14 +948,9 @@ export class WebServer extends EventEmitter {
|
|||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include respawn controller state if active
|
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||||||
const controller = this.respawnControllers.get(id);
|
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||||||
return {
|
return this.getSessionStateWithRespawn(session);
|
||||||
...session.toDetailedState(),
|
|
||||||
respawnEnabled: controller?.getConfig()?.enabled ?? false,
|
|
||||||
respawnConfig: controller?.getConfig() ?? null,
|
|
||||||
respawn: controller?.getStatus() ?? null,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.get('/api/sessions/:id/output', async (req) => {
|
this.app.get('/api/sessions/:id/output', async (req) => {
|
||||||
@@ -2242,7 +2238,7 @@ export class WebServer extends EventEmitter {
|
|||||||
await this.setupSessionListeners(session);
|
await this.setupSessionListeners(session);
|
||||||
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'run_prompt' });
|
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 {
|
try {
|
||||||
const result = await session.runPrompt(prompt);
|
const result = await session.runPrompt(prompt);
|
||||||
@@ -2660,7 +2656,7 @@ export class WebServer extends EventEmitter {
|
|||||||
this.persistSessionState(session);
|
this.persistSessionState(session);
|
||||||
await this.setupSessionListeners(session);
|
await this.setupSessionListeners(session);
|
||||||
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'quick_start' });
|
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
|
// Start in the appropriate mode
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user