agent-ecosystem/src/main/services/team/progressPayload.ts
Mike 297bd8f533 fix(team): cap renderer IPC payloads to prevent OOM crashes
Users with long-running teams (37+ tasks, 10+ agents for an hour) were
hitting constant renderer crashes (issue #36). Two hot paths were
serializing unbounded histories across IPC on every tick:

- Provisioning progress: emitLogsProgress and updateProgress both
  joined the full provisioningOutputParts array (~20 event-driven call
  sites) plus the full CLI log tail, then fanned that out to the
  renderer. After an hour, each tick shipped multi-megabyte payloads
  and Zustand OOM'd on the immutable state clone.

- Session detail cache: SessionDetail.messages (the raw parsed JSONL)
  was being cached and returned over IPC/HTTP even though the renderer
  only reads session/chunks/processes/metrics. This roughly doubled
  the per-entry cache footprint on large sessions.

Fixes:

- Add progressPayload helpers that cap the log tail to 200 lines and
  assistant output to the last 20 parts; empty/whitespace joins
  collapse to undefined so the noop guard is explicit rather than
  coincidental.

- Apply the cap inside emitLogsProgress, updateProgress, and the two
  inline emission paths (stall warning, retry error). Throttle the
  log-progress tick 300ms -> 1000ms so Zustand can keep up.

- Add stripSessionDetailMessages and call it at every SessionDetail
  production site that crosses IPC/HTTP (both sessions.ts routes,
  both cache stores).

- Raise MAX_CACHE_SESSIONS 5 -> 20 now that the per-entry SessionDetail
  footprint is bounded. Previously 5 forced constant re-parsing on
  every session switch.

Tests: 15 new unit tests covering the helpers (tail slicing, empty
parts, whitespace-only parts, non-mutation of inputs).
2026-04-18 17:44:39 +05:00

52 lines
2 KiB
TypeScript

/**
* Helpers that shape provisioning progress payloads before they are emitted
* to the renderer over IPC.
*
* Rationale: the renderer only renders a small "tail" preview of CLI logs
* and assistant output in ProvisioningProgressBlock / CliLogsRichView. Sending
* the full accumulated history on every throttled progress tick (≈ every
* second under load) serialized a multi-megabyte string over IPC and forced
* Zustand to produce a new immutable state object — which triggered renderer
* V8 OOM crashes for users with long-running teams. These helpers keep the
* hot emission path bounded while leaving the full history in-process for
* diagnostics and completion-time reports.
*/
export const PROGRESS_LOG_TAIL_LINES = 200;
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
/**
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
* and trimmed. Returns `undefined` when the tail is empty so callers can
* skip emitting a noop update.
*/
export function buildProgressLogsTail(
lines: readonly string[],
maxLines: number = PROGRESS_LOG_TAIL_LINES
): string | undefined {
if (lines.length === 0) {
return undefined;
}
const effectiveMax = Math.max(1, maxLines);
const tail = lines.length > effectiveMax ? lines.slice(-effectiveMax) : lines;
const joined = tail.join('\n').trim();
return joined.length === 0 ? undefined : joined;
}
/**
* Return the trailing `maxParts` of assistant output parts joined with a
* blank line, matching the renderer's rendering contract. Returns `undefined`
* when no parts are available.
*/
export function buildProgressAssistantOutput(
parts: readonly string[],
maxParts: number = PROGRESS_OUTPUT_TAIL_PARTS
): string | undefined {
if (parts.length === 0) {
return undefined;
}
const effectiveMax = Math.max(1, maxParts);
const tail = parts.length > effectiveMax ? parts.slice(-effectiveMax) : parts;
const joined = tail.join('\n\n');
return joined.trim().length === 0 ? undefined : joined;
}